Only because it has taken me a minute to remember how exactly I did this…learning caps on. Actually, mainly so if it breaks I can fix it. But, in short, this is how to generate and display a Mastodon feed on a homepage. No, I can’t claim credit for the coding completely; coding credit goes almost all to ChatGPT and Claude with some minor tweaks by myself to align CSS coloring and sizing.

System Requirements
Obligatory list of system requirements.
- Working self-hosted site with dedicated domain name running on some flavor of Linux.
- Working Mastodon instance (either on the same server or a different server). If on a different server make sure it is accessible from the web server.
That being said, if you’re with me to this point, you’re capable of building a LAMP stack and the basics, so I won’t get into the nitty gritty of those details. I do find that Webmin is an easier way of building and writing files here, though, just make sure the permissions are correct.
I also recommend a separate account on Mastodon for accessing and generating the feed with non-admin permissions. I use a bot account for most of the automated processing.
Feed Generation
We’re going to generate a full feed of public posts using a Python script. By default, Mastodon does not provide a full feed dump; there is an RSS feed generated for specific endpoints, but the full public feed is not available without some creativity. First up, we need to create a new file.
sudo nano /home/youruser/generate_rss.py
Replace youruser with your user directory; optionally place in the root directory. Next up, insert the following:
import requests
import xml.etree.ElementTree as ET
from xml.dom import minidom
def prettify_xml(elem):
rough_string = ET.tostring(elem, 'utf-8')
reparsed = minidom.parseString(rough_string)
return reparsed.toprettyxml(indent=" ")
instance_url = "https://yourinstanceurl"
public_timeline_url = f"{instance_url}/api/v1/timelines/home"
# Replace these with your actual client credentials and access token
client_id = "yourclientid"
client_secret = "yourclientsecret"
access_token = "youraccesstoken"
# Add cache control headers
headers = {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
# Obtain data using authentication
response = requests.get(public_timeline_url, headers={"Authorization": f"Bearer {access_token}"})
data = response.json()
# Check for errors in the API response
if 'error' in data:
print("Error:", data['error'])
exit()
# Create the root element with the Atom namespace
rss = ET.Element("{http://www.w3.org/2005/Atom}rss")
rss.set("version", "2.0")
# Declare the media namespace
media_namespace = "http://search.yahoo.com/mrss/"
media_prefix = "media"
ET.register_namespace(media_prefix, media_namespace)
channel = ET.SubElement(rss, "channel")
title = ET.SubElement(channel, "title")
title.text = "Schleicher Social Timeline"
link = ET.SubElement(channel, "link")
link.text = public_timeline_url
description = ET.SubElement(channel, "description")
description.text = "Schleicher Social RSS Feed"
# Process the data and generate RSS feed
for item in data:
rss_item = ET.SubElement(channel, "item")
item_title = ET.SubElement(rss_item, "title")
item_title.text = f"New post by {item['account']['display_name']}"
item_link = ET.SubElement(rss_item, "link")
item_link.text = item['url']
item_description = ET.SubElement(rss_item, "description")
item_description.text = item['content']
item_author = ET.SubElement(rss_item, "author")
item_author.text = item['account']['display_name']
item_pub_date = ET.SubElement(rss_item, "pubDate")
item_pub_date.text = item['created_at']
# Add media attachments to the description and media content in <media:content> format
if 'media_attachments' in item:
for media in item['media_attachments']:
if media['type'] == 'image':
# Add image as a separate <media:content> tag
media_content = ET.Element(f"{{{media_namespace}}}content")
media_content.set("height", "151")
media_content.set("medium", "image")
media_content.set("url", media['url'])
media_content.set("width", "151")
# Append image tag to the description
img_tag = f"<img src='{media['url']}' alt='Image'>"
item_description.text += f"<br>{img_tag}"
rss_item.append(media_content)
# Save the RSS feed to a file
with open('/path/to/end/directory/mastodon_rss.xml', 'w') as f:
f.write(prettify_xml(rss))
You’ll need to replace the highlighted elements with your specific configuration. Importantly, leave the rss.xml file name the same (we’ll reference it in the plugin build coming up). I dropped the actual XML in the webroot of the site it is being displayed on (easiest if you need to verify structure and syntax of the generated file).
Set up a system cron to generate the XML
Next step is to tell the system to occasionally fetch the data, since a feed isn’t any good if it doesn’t update…Use whichever cron editor you would like; in this case we’re using crontab as root:
sudo crontab -e
Next we’ll schedule this to run every 15 minutes, replacing highlighted text with your specific directory config:
# Run every 15 minutes
*/15 * * * * /path/to/generate_rss.py
Save and close (Ctrl+X, Y) to exit and enable the config and run. Once done, visiting that file in the browser should display your XML (e.g., https://yoursite/mastodon_rss.xml):

Plugin time
You have a couple of options from here. Option A is to use a third-party site to display the feed (e.g., Elfsight). That being said those often required paid subscriptions to overcome view or display limits. Option 2 is to adapt a plugin; I already did the legwork. Since the first option is fairly self explanatory (of note, the XML is already tailored to Elfsight), I’ll walk you through option 2, creating a plugin. This plugin will generate a carousel with basic config options. Again, I recommend Webmin for ease of editing (or Notepad++ before uploading in command shell).
Once created the plugin will pull up to 20 posts from the RSS XML; you’ll have an admin section in the WordPress dashboard that will allow you to define and select basic parameters (grid size, feed path, AJAX loading, etc.).
Create Directory
First up, create the directory for your plugin (e.g., /public/path/wp-content/plugins/family-mastodon-carousel). In that directory, let’s start with the plugin config. We’ll need to create a new file:
#switch to new directory
cd /path/to/family-mastodon-carousel
In this plugin we’ll create 3 new files. One to define the plugin behavior, one to run the java script for the carousel behavior, and the CSS to display it nicely. Using the text editor, create each of the 3 files.
family.mastodon.carousel.php
<?php
/**
* Plugin Name: Family Mastodon Carousel
* Description: Displays Mastodon RSS feed in a responsive carousel with mobile optimization
* Version: 2.1.0
* Author: Custom Plugin
*/
if (!defined('ABSPATH')) {
exit;
}
class FamilyMastodonCarousel {
private $plugin_name = 'family-mastodon-carousel';
private $version = '2.1.0';
public function __construct() {
add_action('init', array($this, 'init'));
add_action('admin_menu', array($this, 'admin_menu'));
add_action('admin_init', array($this, 'admin_init'));
add_action('wp_enqueue_scripts', array($this, 'enqueue_scripts'));
add_shortcode('mastodon_carousel', array($this, 'shortcode_handler'));
add_action('wp_ajax_test_mastodon_feed', array($this, 'ajax_test_feed'));
add_action('wp_ajax_clear_mastodon_cache', array($this, 'ajax_clear_cache'));
add_action('wp_ajax_load_mastodon_carousel', array($this, 'ajax_load_carousel'));
add_action('wp_ajax_nopriv_load_mastodon_carousel', array($this, 'ajax_load_carousel'));
}
public function init() {
// Plugin initialization
}
public function enqueue_scripts() {
global $post;
if (is_a($post, 'WP_Post') && has_shortcode($post->post_content, 'mastodon_carousel')) {
wp_enqueue_style($this->plugin_name, plugin_dir_url(__FILE__) . 'carousel.css', array(), $this->version);
wp_enqueue_script($this->plugin_name, plugin_dir_url(__FILE__) . 'carousel.js', array('jquery'), $this->version, true);
wp_localize_script($this->plugin_name, 'mastodon_ajax', array(
'ajax_url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('mastodon_carousel_nonce')
));
}
}
private function is_mobile() {
return wp_is_mobile() || (isset($_SERVER['HTTP_USER_AGENT']) && preg_match('/(iPhone|iPod|Android|BlackBerry|Mobile)/', $_SERVER['HTTP_USER_AGENT']));
}
private function prevent_page_caching() {
if (!headers_sent()) {
header('Cache-Control: no-cache, no-store, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
header('CF-Cache-Status: BYPASS');
header('X-WP-Rocket-Cache: bypass');
header('X-W3TC-Cache: bypass');
header('X-LiteSpeed-Cache-Control: no-cache');
}
if (!defined('DONOTCACHEPAGE')) {
define('DONOTCACHEPAGE', true);
}
if (!defined('DONOTCACHECONSTANT')) {
define('DONOTCACHECONSTANT', true);
}
}
public function admin_menu() {
add_options_page(
'Mastodon Carousel Settings',
'Mastodon Carousel',
'manage_options',
$this->plugin_name,
array($this, 'admin_page')
);
}
public function admin_init() {
register_setting($this->plugin_name, $this->plugin_name . '_options');
add_settings_section('feed_settings', 'Feed Settings', array($this, 'section_callback'), $this->plugin_name);
add_settings_field('rss_url', 'RSS Feed URL', array($this, 'rss_url_callback'), $this->plugin_name, 'feed_settings');
add_settings_field('posts_limit', 'Number of Posts', array($this, 'posts_limit_callback'), $this->plugin_name, 'feed_settings');
add_settings_field('cache_duration', 'Cache Duration (minutes)', array($this, 'cache_duration_callback'), $this->plugin_name, 'feed_settings');
add_settings_section('appearance_settings', 'Appearance Settings', array($this, 'section_callback'), $this->plugin_name);
add_settings_field('posts_per_slide', 'Posts Per Slide (Desktop)', array($this, 'posts_per_slide_callback'), $this->plugin_name, 'appearance_settings');
add_settings_field('show_images', 'Show Post Images', array($this, 'show_images_callback'), $this->plugin_name, 'appearance_settings');
add_settings_field('ajax_loading', 'Use AJAX Loading', array($this, 'ajax_loading_callback'), $this->plugin_name, 'appearance_settings');
}
public function section_callback() {
// Section descriptions if needed
}
public function rss_url_callback() {
$options = get_option($this->plugin_name . '_options');
$value = isset($options['rss_url']) ? $options['rss_url'] : 'https://family.schleicher.social/mastodon_rss.xml';
echo '<input type="url" name="' . $this->plugin_name . '_options[rss_url]" value="' . esc_attr($value) . '" class="regular-text" />';
echo '<p class="description">Enter your Mastodon RSS feed URL</p>';
}
public function posts_limit_callback() {
$options = get_option($this->plugin_name . '_options');
$value = isset($options['posts_limit']) ? $options['posts_limit'] : 18;
echo '<input type="number" name="' . $this->plugin_name . '_options[posts_limit]" value="' . esc_attr($value) . '" min="3" max="50" />';
echo '<p class="description">Total posts to display (mobile shows 1 per slide automatically)</p>';
}
public function cache_duration_callback() {
$options = get_option($this->plugin_name . '_options');
$value = isset($options['cache_duration']) ? $options['cache_duration'] : 15;
echo '<input type="number" name="' . $this->plugin_name . '_options[cache_duration]" value="' . esc_attr($value) . '" min="5" max="1440" />';
echo '<p class="description">How long to cache the RSS feed (in minutes)</p>';
}
public function posts_per_slide_callback() {
$options = get_option($this->plugin_name . '_options');
$value = isset($options['posts_per_slide']) ? $options['posts_per_slide'] : 6;
echo '<select name="' . $this->plugin_name . '_options[posts_per_slide]">';
$opts = array(3 => '3 posts (1x3)', 4 => '4 posts (2x2)', 6 => '6 posts (2x3)', 8 => '8 posts (2x4)', 9 => '9 posts (3x3)');
foreach ($opts as $num => $label) {
echo '<option value="' . $num . '"' . selected($num, $value, false) . '>' . $label . '</option>';
}
echo '</select>';
echo '<p class="description">Posts displayed per slide on desktop (mobile always shows 1 post per slide)</p>';
}
public function show_images_callback() {
$options = get_option($this->plugin_name . '_options');
$value = isset($options['show_images']) ? $options['show_images'] : 1;
echo '<input type="checkbox" name="' . $this->plugin_name . '_options[show_images]" value="1" ' . checked(1, $value, false) . ' />';
echo '<label> Display post images when available</label>';
}
public function ajax_loading_callback() {
$options = get_option($this->plugin_name . '_options');
$value = isset($options['ajax_loading']) ? $options['ajax_loading'] : 0;
echo '<input type="checkbox" name="' . $this->plugin_name . '_options[ajax_loading]" value="1" ' . checked(1, $value, false) . ' />';
echo '<label> Load feed content via AJAX (bypasses all caching)</label>';
echo '<p class="description">Enable this if you have aggressive caching that prevents the feed from updating.</p>';
}
public function admin_page() {
?>
<div class="wrap">
<h1>Mastodon Carousel Settings</h1>
<form method="post" action="options.php">
<?php settings_fields($this->plugin_name); do_settings_sections($this->plugin_name); submit_button(); ?>
</form>
<div class="card">
<h3>Test RSS Feed</h3>
<button type="button" id="test-feed-btn" class="button button-secondary">Test Feed</button>
<button type="button" id="clear-cache-btn" class="button button-secondary">Clear Cache</button>
<div id="test-results" style="margin-top: 15px;"></div>
</div>
<div class="card">
<h3>Shortcode Usage</h3>
<p><strong>Basic:</strong> <code>[mastodon_carousel]</code></p>
<p><strong>With params:</strong> <code>[mastodon_carousel limit="12" per_slide="3"]</code></p>
<p><em>Note: Mobile devices show 1 post per slide automatically.</em></p>
</div>
</div>
<script>
jQuery(document).ready(function($) {
$('#test-feed-btn').click(function() {
var $btn = $(this), $results = $('#test-results');
$btn.prop('disabled', true).text('Testing...');
$results.html('<p>Connecting...</p>');
$.ajax({
url: ajaxurl, type: 'POST',
data: { action: 'test_mastodon_feed', nonce: '<?php echo wp_create_nonce('test_mastodon_feed'); ?>' },
success: function(response) {
if (response.success) {
$results.html('<div style="color: green;"><strong>✓ Success!</strong> Found ' + response.data.count + ' posts.</div>');
} else {
$results.html('<div style="color: red;"><strong>✗ Error:</strong> ' + response.data + '</div>');
}
},
complete: function() { $btn.prop('disabled', false).text('Test Feed'); }
});
});
$('#clear-cache-btn').click(function() {
var $btn = $(this), $results = $('#test-results');
$btn.prop('disabled', true).text('Clearing...');
$.ajax({
url: ajaxurl, type: 'POST',
data: { action: 'clear_mastodon_cache', nonce: '<?php echo wp_create_nonce('clear_mastodon_cache'); ?>' },
success: function() { $results.html('<div style="color: green;"><strong>✓</strong> Cache cleared</div>'); },
complete: function() { $btn.prop('disabled', false).text('Clear Cache'); }
});
});
});
</script>
<?php
}
public function ajax_test_feed() {
check_ajax_referer('test_mastodon_feed', 'nonce');
if (!current_user_can('manage_options')) wp_die('Unauthorized');
$options = get_option($this->plugin_name . '_options');
$rss_url = isset($options['rss_url']) ? $options['rss_url'] : '';
if (empty($rss_url)) {
wp_send_json_error('No RSS URL configured');
}
$feed = $this->fetch_rss_feed($rss_url, true);
if (is_wp_error($feed)) {
wp_send_json_error($feed->get_error_message());
} else {
wp_send_json_success(array('count' => count($feed)));
}
}
public function ajax_clear_cache() {
check_ajax_referer('clear_mastodon_cache', 'nonce');
if (!current_user_can('manage_options')) wp_die('Unauthorized');
global $wpdb;
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_mastodon_feed_%'");
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_mastodon_feed_%'");
wp_send_json_success();
}
public function ajax_load_carousel() {
// Skip nonce verification entirely for this public endpoint
// since the feed is public data anyway
$limit = isset($_POST['limit']) ? intval($_POST['limit']) : 18;
$per_slide = isset($_POST['per_slide']) ? intval($_POST['per_slide']) : 6;
$options = get_option($this->plugin_name . '_options');
$rss_url = isset($options['rss_url']) ? $options['rss_url'] : 'https://family.schleicher.social/mastodon_rss.xml';
$show_images = isset($options['show_images']) ? $options['show_images'] : 1;
$feed_items = $this->fetch_rss_feed($rss_url);
if (is_wp_error($feed_items)) {
wp_send_json_error('Unable to load feed: ' . $feed_items->get_error_message());
}
if (empty($feed_items)) {
wp_send_json_error('No posts found in the feed.');
}
$content = $this->generate_carousel_content($feed_items, $limit, $per_slide, $show_images);
wp_send_json_success($content);
}
public function shortcode_handler($atts) {
$options = get_option($this->plugin_name . '_options');
$use_ajax = isset($options['ajax_loading']) ? $options['ajax_loading'] : 0;
if ($use_ajax) {
return $this->ajax_placeholder_content($atts);
} else {
$this->prevent_page_caching();
return $this->generate_shortcode_content($atts);
}
}
private function ajax_placeholder_content($atts) {
$options = get_option($this->plugin_name . '_options');
$atts = shortcode_atts(array(
'limit' => isset($options['posts_limit']) ? $options['posts_limit'] : 18,
'per_slide' => isset($options['posts_per_slide']) ? $options['posts_per_slide'] : 6,
), $atts, 'mastodon_carousel');
$is_mobile = $this->is_mobile();
ob_start();
?>
<div class="schleicher-social-feed <?php echo $is_mobile ? 'mobile-layout' : 'desktop-layout'; ?>"
data-ajax-limit="<?php echo $atts['limit']; ?>" data-ajax-per-slide="<?php echo $atts['per_slide']; ?>">
<div class="carousel-container" style="height: 400px; display: flex; align-items: center; justify-content: center;">
<div class="loading-message">
<p>Loading social feed...</p>
<div class="loading-spinner" style="border: 3px solid #f3f3f3; border-top: 3px solid #d4a017; border-radius: 50%; width: 30px; height: 30px; animation: spin 1s linear infinite; margin: 10px auto;"></div>
</div>
</div>
</div>
<style>@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }</style>
<script>
jQuery(document).ready(function($) {
$('.schleicher-social-feed[data-ajax-limit]').each(function() {
var $container = $(this);
var limit = $container.data('ajax-limit');
var perSlide = $container.data('ajax-per-slide');
$.ajax({
url: mastodon_ajax.ajax_url, type: 'POST',
data: { action: 'load_mastodon_carousel', nonce: mastodon_ajax.nonce, limit: limit, per_slide: perSlide },
success: function(response) {
if (response.success) {
$container.html(response.data);
if (typeof SchleicharSocialCarousel !== 'undefined') {
new SchleicharSocialCarousel($container[0]);
}
} else {
$container.html('<div class="mastodon-carousel-error">Error: ' + response.data + '</div>');
}
},
error: function() {
$container.html('<div class="mastodon-carousel-error">Failed to load social feed.</div>');
}
});
});
});
</script>
<?php
return ob_get_clean();
}
private function generate_shortcode_content($atts) {
$options = get_option($this->plugin_name . '_options');
$atts = shortcode_atts(array(
'limit' => isset($options['posts_limit']) ? $options['posts_limit'] : 18,
'per_slide' => isset($options['posts_per_slide']) ? $options['posts_per_slide'] : 6,
), $atts, 'mastodon_carousel');
$limit = intval($atts['limit']);
$per_slide = intval($atts['per_slide']);
$rss_url = isset($options['rss_url']) ? $options['rss_url'] : 'https://family.schleicher.social/mastodon_rss.xml';
$show_images = isset($options['show_images']) ? $options['show_images'] : 1;
$feed_items = $this->fetch_rss_feed($rss_url);
if (is_wp_error($feed_items)) {
return '<div class="mastodon-carousel-error">Unable to load feed: ' . esc_html($feed_items->get_error_message()) . '</div>';
}
if (empty($feed_items)) {
return '<div class="mastodon-carousel-error">No posts found in the feed.</div>';
}
$carousel_content = $this->generate_carousel_content($feed_items, $limit, $per_slide, $show_images);
$is_mobile = $this->is_mobile();
ob_start();
?>
<div class="schleicher-social-feed <?php echo $is_mobile ? 'mobile-layout' : 'desktop-layout'; ?>"
data-posts-per-slide="<?php echo $per_slide; ?>" data-is-mobile="<?php echo $is_mobile ? 'true' : 'false'; ?>">
<?php echo $carousel_content; ?>
</div>
<?php
return ob_get_clean();
}
private function generate_carousel_content($feed_items, $limit, $per_slide, $show_images) {
$feed_items = array_slice($feed_items, 0, $limit);
$is_mobile = $this->is_mobile();
if ($is_mobile) {
$slides = array();
foreach ($feed_items as $item) {
$slides[] = array($item);
}
$container_height = 'auto';
$grid_cols = 1; $grid_rows = 1;
} else {
$slides = array_chunk($feed_items, $per_slide);
switch ($per_slide) {
case 3: $grid_cols = 3; $grid_rows = 1; $container_height = 480; break;
case 4: $grid_cols = 2; $grid_rows = 2; $container_height = 960; break;
case 6: $grid_cols = 3; $grid_rows = 2; $container_height = 960; break;
case 8: $grid_cols = 4; $grid_rows = 2; $container_height = 960; break;
case 9: $grid_cols = 3; $grid_rows = 3; $container_height = 1440; break;
default: $grid_cols = 3; $grid_rows = 2; $container_height = 960; break;
}
}
$total_slides = count($slides);
ob_start();
?>
<div class="carousel-container" style="height: <?php echo $container_height; ?>px;">
<div class="carousel-track" data-total-slides="<?php echo $total_slides; ?>">
<?php foreach ($slides as $slide_index => $slide_items): ?>
<div class="carousel-slide <?php echo $slide_index === 0 ? 'active' : ''; ?>">
<div class="posts-grid" <?php if (!$is_mobile): ?>style="grid-template-columns: repeat(<?php echo $grid_cols; ?>, 1fr); grid-template-rows: repeat(<?php echo $grid_rows; ?>, 1fr);"<?php endif; ?>>
<?php foreach ($slide_items as $item): ?>
<div class="post-card" data-post-id="<?php echo esc_attr($item['id']); ?>">
<?php if ($show_images && !empty($item['image'])): ?>
<div class="post-image">
<img src="<?php echo esc_url($item['image']); ?>" alt="Post image" loading="lazy" />
</div>
<?php else: ?>
<div class="post-image no-image">
<img src="https://family.schleicher.social/wp-content/uploads/2024/01/pulse-image-16793114010641.png" alt="Schleicher Social" loading="lazy" />
</div>
<?php endif; ?>
<div class="post-content">
<div class="post-header">
<span class="post-author"><?php echo esc_html($item['author']); ?></span>
<span class="post-date"><?php echo esc_html($item['date']); ?></span>
</div>
<div class="post-text">
<?php echo wp_kses_post($item['content']); ?>
</div>
<div class="post-actions">
<?php if (!empty($item['link'])): ?>
<a href="<?php echo esc_url($item['link']); ?>" target="_blank" rel="noopener noreferrer" class="read-more-btn">Read more</a>
<?php endif; ?>
</div>
<div class="post-footer">
<span class="post-source">
<svg class="source-icon" width="16" height="16" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
Schleicher Social
</span>
<?php if (!empty($item['link'])): ?>
<a href="<?php echo esc_url($item['link']); ?>" target="_blank" rel="noopener noreferrer" class="share-btn">Share</a>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if ($total_slides > 1): ?>
<button class="carousel-prev" aria-label="Previous slide">‹</button>
<button class="carousel-next" aria-label="Next slide">›</button>
<?php endif; ?>
</div>
<?php if ($total_slides > 1): ?>
<div class="carousel-dots">
<?php for ($i = 0; $i < $total_slides; $i++): ?>
<button class="carousel-dot <?php echo $i === 0 ? 'active' : ''; ?>" data-slide="<?php echo $i; ?>" aria-label="Go to slide <?php echo ($i + 1); ?>"></button>
<?php endfor; ?>
</div>
<?php endif; ?>
<?php
return ob_get_clean();
}
private function fetch_rss_feed($url, $force_refresh = false) {
if (empty($url)) {
return new WP_Error('empty_url', 'RSS URL is empty');
}
$options = get_option($this->plugin_name . '_options');
$cache_duration = isset($options['cache_duration']) ? $options['cache_duration'] : 15;
$cache_key = 'mastodon_feed_' . md5($url);
if (!$force_refresh) {
$cached_data = get_transient($cache_key);
if ($cached_data !== false) {
return $cached_data;
}
}
$response = wp_remote_get($url, array(
'timeout' => 30,
'headers' => array('User-Agent' => 'WordPress/' . get_bloginfo('version') . '; ' . get_bloginfo('url'))
));
if (is_wp_error($response)) {
return $response;
}
$body = wp_remote_retrieve_body($response);
if (empty($body)) {
return new WP_Error('empty_feed', 'RSS feed returned empty response');
}
libxml_use_internal_errors(true);
$xml = simplexml_load_string($body);
if ($xml === false) {
$errors = libxml_get_errors();
$error_msg = !empty($errors) ? $errors[0]->message : 'Invalid XML format';
return new WP_Error('invalid_xml', 'Feed parsing error: ' . $error_msg);
}
$items = array();
if (isset($xml->channel->item)) {
foreach ($xml->channel->item as $item) {
$title = (string) $item->title;
$link = (string) $item->link;
$pub_date = (string) $item->pubDate;
$description = (string) $item->description;
$content = $this->clean_mastodon_content($description);
if (empty($content) || strlen($content) < 20) {
$content = $this->clean_mastodon_content($title);
}
$image = '';
$item->registerXPathNamespace('media', 'http://search.yahoo.com/mrss/');
$media_content = $item->xpath('media:content[@type="image/jpeg" or @type="image/png" or @type="image/gif" or @type="image/webp"]');
if (!empty($media_content)) {
$image = (string) $media_content[0]['url'];
}
if (empty($image)) {
$media_content = $item->xpath('media:content[@url]');
if (!empty($media_content)) {
$url_check = (string) $media_content[0]['url'];
if (preg_match('/\.(jpg|jpeg|png|gif|webp)$/i', $url_check) || strpos($url_check, '/media_attachments/') !== false) {
$image = $url_check;
}
}
}
if (empty($image) && isset($item->enclosure)) {
foreach ($item->enclosure as $enclosure) {
$enc_type = (string) $enclosure['type'];
if (strpos($enc_type, 'image/') === 0) {
$image = (string) $enclosure['url'];
break;
}
}
}
$author = 'The Schleicher Bot';
if (isset($item->author)) {
$author = (string) $item->author;
} elseif (isset($item->children('dc', true)->creator)) {
$author = (string) $item->children('dc', true)->creator;
}
$date = '';
if (!empty($pub_date)) {
$timestamp = strtotime($pub_date);
if ($timestamp !== false) {
$date = date('F j', $timestamp);
}
}
$post_id = md5($link . $pub_date);
$items[] = array(
'id' => $post_id,
'title' => $title,
'content' => $content,
'link' => $link,
'date' => $date,
'author' => $author,
'image' => $image
);
}
}
if (!empty($items)) {
set_transient($cache_key, $items, $cache_duration * MINUTE_IN_SECONDS);
}
return $items;
}
private function clean_mastodon_content($html) {
$html = preg_replace('/<span class="invisible"[^>]*>.*?<\/span>/is', '', $html);
$html = preg_replace('/<span class="ellipsis"[^>]*>(.*?)<\/span>/is', '$1...', $html);
$html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$html = str_replace(array('<br>', '<br/>', '<br />', '</p>'), "\n", $html);
$html = strip_tags($html);
$html = preg_replace('/\n\s*\n/', "\n\n", $html);
$html = preg_replace('/[ \t]+/', ' ', $html);
$html = trim($html);
$html = nl2br($html);
if (strlen($html) > 200) {
$html = substr($html, 0, 197);
$last_space = strrpos($html, ' ');
if ($last_space !== false && $last_space > 150) {
$html = substr($html, 0, $last_space);
}
$html .= '...';
}
if (strlen($html) < 10) {
$html = 'View full post for details.';
}
return $html;
}
}
// Initialize the plugin
new FamilyMastodonCarousel();
// Activation hook
register_activation_hook(__FILE__, function() {
$default_options = array(
'rss_url' => 'https://yoursite.com/mastodon_rss.xml',
'posts_limit' => 18,
'cache_duration' => 15,
'posts_per_slide' => 6,
'show_images' => 1,
'ajax_loading' => 0
);
add_option('family-mastodon-carousel_options', $default_options);
});
// Deactivation hook
register_deactivation_hook(__FILE__, function() {
global $wpdb;
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_mastodon_feed_%'");
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_mastodon_feed_%'");
});
?>
carousel.js
/**
* Schleicher Social Feed JavaScript - Mobile Optimized Carousel
* Version 2.1.0 - Single post per slide on mobile
*/
(function($) {
'use strict';
class SchleicharSocialCarousel {
constructor(container) {
this.container = $(container);
this.track = this.container.find('.carousel-track');
this.slides = this.container.find('.carousel-slide');
this.dots = this.container.find('.carousel-dot');
this.prevBtn = this.container.find('.carousel-prev');
this.nextBtn = this.container.find('.carousel-next');
this.currentSlide = 0;
this.originalSlides = this.slides.length;
this.totalSlides = this.originalSlides;
this.autoPlayInterval = null;
this.isAutoPlay = true;
this.autoPlayDelay = 6000;
this.isMobile = window.innerWidth <= 480;
this.mobileSlides = [];
this.originalPosts = [];
// Store original posts data
this.storeOriginalData();
if (this.totalSlides > 1 || this.isMobile) {
this.init();
} else {
// Hide navigation if only one slide
this.prevBtn.hide();
this.nextBtn.hide();
this.dots.parent().hide();
}
}
storeOriginalData() {
// Store all post cards from all slides
this.slides.each((slideIndex, slide) => {
const $slide = $(slide);
const posts = $slide.find('.post-card').toArray();
this.originalPosts.push(...posts);
});
}
init() {
this.handleResize(); // Set up initial layout
this.bindEvents();
this.updateNavigation();
if (this.totalSlides > 1) {
this.startAutoPlay();
}
}
bindEvents() {
// Previous button
this.prevBtn.on('click', (e) => {
e.preventDefault();
this.prevSlide();
});
// Next button
this.nextBtn.on('click', (e) => {
e.preventDefault();
this.nextSlide();
});
// Dot navigation
this.dots.on('click', (e) => {
e.preventDefault();
const slideIndex = parseInt($(e.target).data('slide'));
if (!isNaN(slideIndex)) {
this.goToSlide(slideIndex);
}
});
// Keyboard navigation
this.container.on('keydown', (e) => {
if (!this.container.is(':focus-within')) return;
switch(e.which) {
case 37: // Left arrow
e.preventDefault();
this.prevSlide();
break;
case 39: // Right arrow
e.preventDefault();
this.nextSlide();
break;
case 32: // Space bar
e.preventDefault();
this.toggleAutoPlay();
break;
}
});
// Pause autoplay on hover
this.container.on('mouseenter', () => {
this.pauseAutoPlay();
});
this.container.on('mouseleave', () => {
if (this.isAutoPlay) {
this.startAutoPlay();
}
});
// Touch/swipe support
this.addTouchSupport();
// Pause autoplay when page is not visible
$(document).on('visibilitychange', () => {
if (document.hidden) {
this.pauseAutoPlay();
} else if (this.isAutoPlay) {
this.startAutoPlay();
}
});
// Handle window resize
$(window).on('resize', () => {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = setTimeout(() => {
this.handleResize();
}, 250);
});
// Handle post card interactions
this.container.find('.post-card').on('mouseenter', function() {
$(this).addClass('hovered');
}).on('mouseleave', function() {
$(this).removeClass('hovered');
});
}
handleResize() {
const wasMobile = this.isMobile;
this.isMobile = window.innerWidth <= 480;
// If mobile state changed, rebuild carousel
if (wasMobile !== this.isMobile) {
this.rebuildCarousel();
}
}
rebuildCarousel() {
this.pauseAutoPlay();
if (this.isMobile) {
this.createMobileLayout();
} else {
this.restoreDesktopLayout();
}
this.updateDots();
this.currentSlide = 0;
this.goToSlide(0);
if (this.totalSlides > 1) {
this.startAutoPlay();
}
}
createMobileLayout() {
// Clear existing slides
this.track.empty();
// Create individual slides for each post
this.mobileSlides = [];
this.originalPosts.forEach((post, index) => {
const $slide = $('<div class="carousel-slide"></div>');
if (index === 0) {
$slide.addClass('active');
}
const $grid = $('<div class="posts-grid"></div>');
$grid.append($(post).clone());
$slide.append($grid);
this.track.append($slide);
this.mobileSlides.push($slide);
});
this.slides = this.container.find('.carousel-slide');
this.totalSlides = this.originalPosts.length;
// Show navigation even for single slide on mobile
this.prevBtn.show();
this.nextBtn.show();
this.dots.parent().show();
}
restoreDesktopLayout() {
// Clear mobile slides
this.track.empty();
// Get posts per slide from container data attribute
const postsPerSlide = parseInt(this.container.data('posts-per-slide')) || 6;
// Recreate original multi-post slides
const slideChunks = [];
for (let i = 0; i < this.originalPosts.length; i += postsPerSlide) {
slideChunks.push(this.originalPosts.slice(i, i + postsPerSlide));
}
slideChunks.forEach((posts, slideIndex) => {
const $slide = $('<div class="carousel-slide"></div>');
if (slideIndex === 0) {
$slide.addClass('active');
}
const $grid = $('<div class="posts-grid"></div>');
posts.forEach(post => {
$grid.append($(post).clone());
});
$slide.append($grid);
this.track.append($slide);
});
this.slides = this.container.find('.carousel-slide');
this.totalSlides = slideChunks.length;
// Hide navigation if only one slide
if (this.totalSlides <= 1) {
this.prevBtn.hide();
this.nextBtn.hide();
this.dots.parent().hide();
} else {
this.prevBtn.show();
this.nextBtn.show();
this.dots.parent().show();
}
}
updateDots() {
// Remove existing dots
this.dots.parent().empty();
// Create new dots
const $dotsContainer = this.dots.parent();
for (let i = 0; i < this.totalSlides; i++) {
const $dot = $('<button class="carousel-dot"></button>');
$dot.attr('data-slide', i);
$dot.attr('aria-label', `Go to slide ${i + 1}`);
if (i === 0) {
$dot.addClass('active');
}
$dotsContainer.append($dot);
}
// Update dots reference
this.dots = this.container.find('.carousel-dot');
// Rebind dot events
this.dots.off('click').on('click', (e) => {
e.preventDefault();
const slideIndex = parseInt($(e.target).data('slide'));
if (!isNaN(slideIndex)) {
this.goToSlide(slideIndex);
}
});
}
addTouchSupport() {
let startX = 0;
let startY = 0;
let endX = 0;
let endY = 0;
const minSwipeDistance = 60;
let isScrolling = false;
this.container.on('touchstart', (e) => {
const touch = e.originalEvent.touches[0];
startX = touch.clientX;
startY = touch.clientY;
isScrolling = false;
});
this.container.on('touchmove', (e) => {
if (isScrolling) return;
const touch = e.originalEvent.touches[0];
const deltaX = Math.abs(touch.clientX - startX);
const deltaY = Math.abs(touch.clientY - startY);
// Determine if user is scrolling vertically or swiping horizontally
if (deltaY > deltaX && deltaY > 10) {
isScrolling = true;
return;
}
if (deltaX > 10) {
e.preventDefault(); // Prevent scrolling when swiping
}
});
this.container.on('touchend', (e) => {
if (isScrolling) return;
const touch = e.originalEvent.changedTouches[0];
endX = touch.clientX;
endY = touch.clientY;
const deltaX = endX - startX;
const deltaY = endY - startY;
// Check if horizontal swipe is longer than vertical
if (Math.abs(deltaX) > Math.abs(deltaY)) {
if (Math.abs(deltaX) > minSwipeDistance) {
if (deltaX > 0) {
this.prevSlide(); // Swipe right = previous
} else {
this.nextSlide(); // Swipe left = next
}
}
}
});
}
goToSlide(index) {
if (index < 0 || index >= this.totalSlides || index === this.currentSlide) {
return;
}
// Remove active class from current slide and dot
this.slides.eq(this.currentSlide).removeClass('active');
this.dots.eq(this.currentSlide).removeClass('active');
// Update current slide index
this.currentSlide = index;
// Add active class to new slide and dot
this.slides.eq(this.currentSlide).addClass('active');
this.dots.eq(this.currentSlide).addClass('active');
this.updateNavigation();
this.resetAutoPlay();
// Announce slide change for screen readers
this.announceSlideChange();
}
nextSlide() {
const nextIndex = (this.currentSlide + 1) % this.totalSlides;
this.goToSlide(nextIndex);
}
prevSlide() {
const prevIndex = (this.currentSlide - 1 + this.totalSlides) % this.totalSlides;
this.goToSlide(prevIndex);
}
updateNavigation() {
// Update button states for accessibility
this.prevBtn.attr('aria-disabled', this.totalSlides <= 1);
this.nextBtn.attr('aria-disabled', this.totalSlides <= 1);
// Update dots
this.dots.attr('aria-pressed', 'false');
this.dots.eq(this.currentSlide).attr('aria-pressed', 'true');
}
startAutoPlay() {
if (this.totalSlides <= 1) return;
this.pauseAutoPlay(); // Clear any existing interval
this.autoPlayInterval = setInterval(() => {
this.nextSlide();
}, this.autoPlayDelay);
}
pauseAutoPlay() {
if (this.autoPlayInterval) {
clearInterval(this.autoPlayInterval);
this.autoPlayInterval = null;
}
}
resetAutoPlay() {
if (this.isAutoPlay) {
this.startAutoPlay();
}
}
toggleAutoPlay() {
this.isAutoPlay = !this.isAutoPlay;
if (this.isAutoPlay) {
this.startAutoPlay();
} else {
this.pauseAutoPlay();
}
// Provide feedback to user
const message = this.isAutoPlay ? 'Auto-play enabled' : 'Auto-play disabled';
this.announceToScreenReader(message);
}
announceSlideChange() {
const slideNum = this.currentSlide + 1;
const message = `Showing slide ${slideNum} of ${this.totalSlides}`;
this.announceToScreenReader(message);
}
announceToScreenReader(message) {
// Create or update live region for screen reader announcements
let liveRegion = this.container.find('.sr-live-region');
if (liveRegion.length === 0) {
liveRegion = $('<div class="sr-live-region" aria-live="polite" style="position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden;"></div>');
this.container.append(liveRegion);
}
liveRegion.text(message);
}
// Method to manually refresh carousel (useful if content changes)
refresh() {
this.storeOriginalData();
this.rebuildCarousel();
}
// Method to go to a specific post (useful for deep linking)
goToPost(postId) {
// Find the slide containing the post with this ID
if (this.isMobile) {
// In mobile mode, each post is its own slide
this.originalPosts.forEach((post, index) => {
if ($(post).attr('data-post-id') === postId) {
this.goToSlide(index);
return false;
}
});
} else {
// In desktop mode, search within slides
this.slides.each((index, slide) => {
if ($(slide).find(`[data-post-id="${postId}"]`).length > 0) {
this.goToSlide(index);
return false; // Break the loop
}
});
}
}
}
// Initialize carousels when document is ready
$(document).ready(function() {
$('.schleicher-social-feed').each(function() {
new SchleicharSocialCarousel(this);
});
});
// Handle dynamic content loading (for AJAX-loaded content)
$(document).on('DOMNodeInserted', function(e) {
const $target = $(e.target);
if ($target.hasClass('schleicher-social-feed') || $target.find('.schleicher-social-feed').length) {
$target.find('.schleicher-social-feed').each(function() {
if (!$(this).data('carousel-initialized')) {
new SchleicharSocialCarousel(this);
$(this).data('carousel-initialized', true);
}
});
}
});
// Expose the class globally for manual initialization
window.SchleicharSocialCarousel = SchleicharSocialCarousel;
})(jQuery);
carousel.css
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Carousel CSS - Version 2.1.0</title>
<style>
/* Schleicher Social Feed - Carousel Styles */
/* Version 2.1.0 - Mobile Optimized with Bottom Padding Fix */
/* Main container */
.schleicher-social-feed {
width: 100%;
max-width: 100%;
margin: 20px auto;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
position: relative;
}
/* Carousel wrapper */
.carousel-container {
position: relative;
width: 100%;
overflow: visible;
background: transparent;
border-radius: 12px;
padding: 20px;
box-sizing: border-box;
}
/* Track for slides */
.carousel-track {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
/* Individual slides */
.carousel-slide {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
visibility: hidden;
transition: opacity 0.5s ease, visibility 0.5s ease;
}
.carousel-slide.active {
opacity: 1;
visibility: visible;
position: relative;
}
/* Grid layout */
.posts-grid {
display: grid;
gap: 20px;
height: 100%;
width: 100%;
box-sizing: border-box;
}
/* Post card */
.post-card {
background: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
display: flex;
flex-direction: column;
height: 400px;
min-height: 400px;
max-height: 400px;
position: relative;
}
.post-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
/* Post image */
.post-image {
width: 100%;
height: 160px;
overflow: hidden;
position: relative;
flex-shrink: 0;
}
.post-image img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.3s ease;
}
.post-card:hover .post-image img {
transform: scale(1.05);
}
/* Placeholder image styling */
.post-image.no-image {
background: linear-gradient(135deg, #1372B7 0%, #0a3d62 100%);
display: flex;
align-items: center;
justify-content: center;
}
.post-image.no-image img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.95;
}
/* Post content area */
.post-content {
padding: 16px;
display: flex;
flex-direction: column;
flex-grow: 1;
position: relative;
}
/* Post header */
.post-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
.post-author {
font-weight: 600;
color: #1a1a1a;
font-size: 14px;
}
.post-date {
color: #666;
font-size: 12px;
}
/* Post text content */
.post-text {
color: #333;
line-height: 1.6;
font-size: 14px;
margin-bottom: 15px;
flex-grow: 1;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
word-wrap: break-word;
}
/* Post actions */
.post-actions {
margin-top: auto;
padding-bottom: 10px;
}
.read-more-btn {
display: inline-block;
padding: 8px 20px;
background: #8e0303;
color: white !important;
text-decoration: none !important;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
transition: background 0.3s ease, transform 0.2s ease;
cursor: pointer;
}
.read-more-btn:hover {
background: #666666;
transform: translateY(-1px);
text-decoration: none !important;
}
/* Post footer */
.post-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
margin-top: auto;
}
.post-source {
display: flex;
align-items: center;
color: #666;
font-size: 12px;
}
.source-icon {
width: 16px;
height: 16px;
margin-right: 6px;
fill: #0a3d62;
}
.share-btn {
display: inline-flex;
align-items: center;
padding: 5px 12px;
border: 1px solid #666666;
border-radius: 4px;
color: #ffffff !important;
text-decoration: none !important;
font-size: 12px;
transition: all 0.3s ease;
background: #666666;
cursor: pointer;
}
.share-btn:hover {
border-color: #d4a017;
color: #666666 !important;
background: #d4a017;
text-decoration: none !important;
}
/* Navigation buttons */
.carousel-prev,
.carousel-next {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: #d4a017;
color: white;
border: 2px solid #d4a017;
width: 44px;
height: 44px;
border-radius: 50%;
cursor: pointer;
font-size: 24px;
font-weight: normal;
transition: all 0.3s ease;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
line-height: 1;
}
.carousel-prev:hover,
.carousel-next:hover {
background: #0a3d62;
color: white;
border-color: #0a3d62;
box-shadow: 0 4px 12px rgba(29, 161, 242, 0.3);
}
.carousel-prev {
left: -60px;
}
.carousel-next {
right: -60px;
}
/* Dots navigation */
.carousel-dots {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 20px;
padding: 10px;
}
.carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
background: #d0d0d0;
cursor: pointer;
transition: all 0.3s ease;
padding: 0;
}
.carousel-dot:hover {
background: #0a3d62;
transform: scale(1.2);
}
.carousel-dot.active {
width: 24px;
border-radius: 4px;
background: #8e0303;
}
/* Layout variations based on posts per slide */
/* 3 posts (1x3) - Single row */
.schleicher-social-feed[data-posts-per-slide="3"] .carousel-container {
height: 480px !important;
}
.schleicher-social-feed[data-posts-per-slide="3"] .posts-grid {
grid-template-columns: repeat(3, 1fr) !important;
grid-template-rows: 1fr !important;
}
/* 4 posts (2x2) */
.schleicher-social-feed[data-posts-per-slide="4"] .carousel-container {
height: 960px !important;
}
.schleicher-social-feed[data-posts-per-slide="4"] .posts-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
}
/* 6 posts (2x3) - Default */
.schleicher-social-feed[data-posts-per-slide="6"] .carousel-container {
height: 960px !important;
}
.schleicher-social-feed[data-posts-per-slide="6"] .posts-grid {
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(2, 1fr);
}
/* 8 posts (2x4) */
.schleicher-social-feed[data-posts-per-slide="8"] .carousel-container {
height: 960px !important;
}
.schleicher-social-feed[data-posts-per-slide="8"] .posts-grid {
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(2, 1fr);
}
/* 9 posts (3x3) */
.schleicher-social-feed[data-posts-per-slide="9"] .carousel-container {
height: 1440px !important;
}
.schleicher-social-feed[data-posts-per-slide="9"] .posts-grid {
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
}
/* Error message */
.mastodon-carousel-error {
padding: 20px;
text-align: center;
color: #e74c3c;
background: #fdf2f2;
border: 1px solid #f5c6cb;
border-radius: 8px;
margin: 20px auto;
max-width: 600px;
}
/* Loading state */
.schleicher-social-feed.loading {
min-height: 440px;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.schleicher-social-feed.loading::after {
content: 'Loading posts...';
font-size: 16px;
}
/* Loading spinner animation */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Responsive design - Tablet */
@media (max-width: 1024px) {
.carousel-container {
padding: 15px;
}
.posts-grid {
gap: 15px;
}
/* Maintain 3 columns for 1x3 layout on tablets */
.schleicher-social-feed[data-posts-per-slide="3"] .posts-grid {
grid-template-columns: repeat(3, 1fr) !important;
}
/* Adjust other layouts */
.schleicher-social-feed[data-posts-per-slide="6"] .posts-grid,
.schleicher-social-feed[data-posts-per-slide="8"] .posts-grid,
.schleicher-social-feed[data-posts-per-slide="9"] .posts-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: auto;
}
}
/* Responsive design - Small Tablet */
@media (max-width: 768px) {
.carousel-container {
padding: 12px;
height: auto !important;
}
.posts-grid {
grid-template-columns: repeat(2, 1fr) !important;
grid-template-rows: auto !important;
gap: 12px;
}
.post-image {
height: 140px;
}
.post-card {
min-height: auto;
}
.carousel-prev,
.carousel-next {
width: 38px;
height: 38px;
font-size: 20px;
left: -50px;
right: -50px;
}
}
/* Mobile Optimization - 1 POST PER SLIDE */
@media (max-width: 480px) {
/* Mobile layout gets proper structure from PHP */
.schleicher-social-feed.mobile-layout .carousel-container {
padding: 15px;
height: auto !important;
min-height: 520px;
}
/* Mobile grid styling */
.schleicher-social-feed.mobile-layout .posts-grid {
display: flex !important;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
/* Make the single card larger and centered on mobile */
.schleicher-social-feed.mobile-layout .post-card {
height: auto !important;
min-height: 480px;
max-height: none;
width: 100%;
max-width: 360px;
margin: 0 auto;
display: flex;
flex-direction: column;
}
.schleicher-social-feed.mobile-layout .post-image {
height: 220px;
flex-shrink: 0;
}
.schleicher-social-feed.mobile-layout .post-content {
padding: 18px;
display: flex;
flex-direction: column;
flex-grow: 1;
min-height: 0;
}
.schleicher-social-feed.mobile-layout .post-text {
font-size: 15px;
-webkit-line-clamp: 4;
margin-bottom: 18px;
line-height: 1.5;
}
.schleicher-social-feed.mobile-layout .post-author {
font-size: 15px;
}
.schleicher-social-feed.mobile-layout .post-date {
font-size: 13px;
}
.schleicher-social-feed.mobile-layout .read-more-btn {
padding: 10px 24px;
font-size: 15px;
}
.schleicher-social-feed.mobile-layout .post-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
margin-top: auto;
flex-shrink: 0;
}
/* Show navigation arrows on mobile */
.schleicher-social-feed.mobile-layout .carousel-prev,
.schleicher-social-feed.mobile-layout .carousel-next {
display: flex !important;
width: 40px;
height: 40px;
font-size: 22px;
}
.schleicher-social-feed.mobile-layout .carousel-prev {
left: -55px;
}
.schleicher-social-feed.mobile-layout .carousel-next {
right: -55px;
}
.schleicher-social-feed.mobile-layout .carousel-dots {
padding: 15px 8px;
gap: 8px;
margin-top: 15px;
}
.schleicher-social-feed.mobile-layout .carousel-dot {
width: 8px;
height: 8px;
}
.schleicher-social-feed.mobile-layout .carousel-dot.active {
width: 20px;
}
/* Fallback for any non-mobile-layout on mobile screens */
.schleicher-social-feed:not(.mobile-layout) .carousel-container {
height: auto !important;
padding: 12px;
}
.schleicher-social-feed:not(.mobile-layout) .posts-grid {
grid-template-columns: 1fr !important;
grid-template-rows: auto !important;
gap: 12px;
}
.schleicher-social-feed:not(.mobile-layout) .post-card {
height: auto !important;
min-height: 300px;
}
}
/* Extra small screens */
@media (max-width: 360px) {
.carousel-container {
padding: 12px;
}
.schleicher-social-feed.mobile-layout .post-card {
max-width: 100%;
}
.carousel-prev,
.carousel-next {
left: -45px !important;
right: -45px !important;
}
}
/* Accessibility */
.carousel-prev:focus,
.carousel-next:focus,
.carousel-dot:focus,
.read-more-btn:focus,
.share-btn:focus {
outline: 2px solid #1da1f2;
outline-offset: 2px;
}
/* Remove unwanted link styles */
.post-card a {
box-shadow: none !important;
}
/* Screen reader only content */
.sr-live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
/* Print styles */
@media print {
.carousel-prev,
.carousel-next,
.carousel-dots {
display: none;
}
.carousel-slide {
position: static;
opacity: 1;
visibility: visible;
}
.posts-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.carousel-slide,
.post-card,
.post-image img,
.carousel-prev,
.carousel-next,
.carousel-dot,
.read-more-btn,
.share-btn {
transition: none;
}
}
</style>
Once complete you’ll have a new plugin in the WordPress plugin page. Note that if you want to edit the color and styles, this is a good time to edit carousel.css to change any associated coloring. The plugin does include a basic admin menu, but it does not currently allow for CSS changes/editing.
In the plugin page, enable the plugin. To generate the feed, head to the admin settings of the plugin (Settings→Mastodon Carousel) and enter the path to your XML file. Determine if you want to display images with posts as well as the cache time. Optionally, use AJAX if you want to bypass caching. Finally, test the RSS feed to see if everything is set up correctly.
Display on the page
Lastly drop the shortcode (mastodon_carousel) wherever you would like to display the feed. Optionally, you can define specific parameters by passing those in the shortcode (e.g., limiting number of posts or posts per slide).

At the end, you’ll have a functional carousel with basic config options, auto-play (changes every 5 seconds), and a nice, clean look with the full public feed from your Mastodon instance.