diff --git a/includes/class-newspack.php b/includes/class-newspack.php
index 606fcf9762..67297b2495 100644
--- a/includes/class-newspack.php
+++ b/includes/class-newspack.php
@@ -159,6 +159,7 @@ private function includes() {
include_once NEWSPACK_ABSPATH . 'includes/wizards/class-setup-wizard.php';
include_once NEWSPACK_ABSPATH . 'includes/wizards/class-components-demo.php';
+ include_once NEWSPACK_ABSPATH . 'includes/wizards/class-rolling-content-demo.php';
include_once NEWSPACK_ABSPATH . 'includes/wizards/traits/trait-wizards-admin-header.php';
@@ -176,6 +177,7 @@ private function includes() {
include_once NEWSPACK_ABSPATH . 'includes/wizards/class-setup-wizard.php';
include_once NEWSPACK_ABSPATH . 'includes/wizards/class-components-demo.php';
+ include_once NEWSPACK_ABSPATH . 'includes/wizards/class-rolling-content-demo.php';
// Listings Wizard.
include_once NEWSPACK_ABSPATH . 'includes/wizards/class-listings-wizard.php';
diff --git a/includes/class-wizards.php b/includes/class-wizards.php
index 873b72558f..d222257914 100644
--- a/includes/class-wizards.php
+++ b/includes/class-wizards.php
@@ -42,6 +42,7 @@ public static function init() {
public static function init_wizards() {
self::$wizards = [
'components-demo' => new Components_Demo(),
+ 'rolling-content-demo' => new Rolling_Content_Demo(),
// v2 Information Architecture.
'newspack-dashboard' => new Newspack_Dashboard(),
'setup' => new Setup_Wizard(),
diff --git a/includes/wizards/class-rolling-content-demo.php b/includes/wizards/class-rolling-content-demo.php
new file mode 100644
index 0000000000..8b7f0985e0
--- /dev/null
+++ b/includes/wizards/class-rolling-content-demo.php
@@ -0,0 +1,209 @@
+slug, self::SLUG_ADD ], true );
+ }
+
+ /**
+ * Render the container div. Override the parent so the id matches the current page slug,
+ * not `$this->slug`. Required because React mounts into `getElementById(pageParam)`.
+ */
+ public function render_wizard() {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
+ $id = in_array( $page, [ $this->slug, self::SLUG_ADD ], true ) ? $page : $this->slug;
+ ?>
+
+ is_wizard_page() ) {
+ return;
+ }
+
+ add_filter( 'parent_file', [ $this, 'set_parent_file' ] );
+ add_filter( 'submenu_file', [ $this, 'set_submenu_file' ] );
+
+ $icon = sprintf(
+ 'data:image/svg+xml;base64,%s',
+ base64_encode( Newspack_UI_Icons::get_svg( 'collections' ) )
+ );
+
+ // Anchor the menu near the top of the sidebar (just after Dashboard at pos 2)
+ // so the demo is easy to find — `add_menu_page` defaults to appending, which
+ // pushes the demo below every other plugin's menu.
+ add_menu_page(
+ $this->get_name(),
+ $this->get_name(),
+ $this->capability,
+ $this->slug,
+ [ $this, 'render_wizard' ],
+ $icon,
+ '2.5'
+ );
+ add_submenu_page(
+ $this->slug,
+ $this->get_name(),
+ __( 'All Rolling Content', 'newspack-plugin' ),
+ $this->capability,
+ $this->slug,
+ [ $this, 'render_wizard' ]
+ );
+ add_submenu_page(
+ $this->slug,
+ __( 'Add Rolling Content', 'newspack-plugin' ),
+ __( 'Add Rolling Content', 'newspack-plugin' ),
+ $this->capability,
+ self::SLUG_ADD,
+ [ $this, 'render_wizard' ]
+ );
+ }
+
+ /**
+ * Force-highlight the top-level menu when on any rolling-content page.
+ * Required because WP's automatic highlighting can lose the trail through
+ * the conditional menu registration.
+ *
+ * @param string $parent_file The current parent file.
+ * @return string
+ */
+ public function set_parent_file( $parent_file ) {
+ return $this->slug;
+ }
+
+ /**
+ * Highlight the correct submenu item for the current page.
+ *
+ * @param string $submenu_file The current submenu file.
+ * @return string
+ */
+ public function set_submenu_file( $submenu_file ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
+ return in_array( $page, [ $this->slug, self::SLUG_ADD ], true ) ? $page : $submenu_file;
+ }
+
+ /**
+ * Enqueue scripts and styles.
+ *
+ * Replicates the relevant parts of the parent class's enqueue logic. We can't
+ * just call `parent::enqueue_scripts_and_styles()` because its slug check uses
+ * `filter_input(INPUT_GET, 'page')`, which reads from the frozen request state
+ * and ignores any local `$_GET` mutations — so it would bail on the SLUG_ADD
+ * page.
+ */
+ public function enqueue_scripts_and_styles() {
+ if ( ! $this->is_wizard_page() ) {
+ return;
+ }
+
+ Newspack::load_common_assets();
+
+ // Data carrier (no source script).
+ wp_register_script( 'newspack_data', '', [], '1.0', false );
+
+ $plugin_data = get_plugin_data( NEWSPACK_PLUGIN_FILE );
+ $urls = [
+ 'dashboard' => Wizards::get_url( 'newspack-dashboard' ),
+ 'public_path' => Newspack::plugin_url() . '/dist/',
+ 'bloginfo' => [ 'name' => get_bloginfo( 'name' ) ],
+ 'plugin_version' => [ 'label' => $plugin_data['Name'] . ' ' . $plugin_data['Version'] ],
+ 'homepage' => get_edit_post_link( get_option( 'page_on_front', false ) ),
+ 'site' => get_site_url(),
+ 'support' => esc_url( 'https://help.newspack.com/' ),
+ 'support_email' => false,
+ ];
+
+ $aux_data = [
+ 'is_e2e' => Starter_Content::is_e2e(),
+ 'is_debug_mode' => Newspack::is_debug_mode(),
+ 'has_completed_setup' => get_option( NEWSPACK_SETUP_COMPLETE ),
+ 'site_title' => get_option( 'blogname' ),
+ 'is_managed' => method_exists( 'Newspack_Manager', 'is_connected_to_manager' ) && \Newspack_Manager::is_connected_to_manager(),
+ ];
+
+ wp_localize_script( 'newspack_data', 'newspack_urls', $urls );
+ wp_localize_script( 'newspack_data', 'newspack_aux_data', $aux_data );
+ wp_enqueue_script( 'newspack_data' );
+
+ wp_register_script(
+ 'newspack-wizards',
+ Newspack::plugin_url() . '/dist/wizards.js',
+ $this->get_script_dependencies(),
+ NEWSPACK_PLUGIN_VERSION,
+ true
+ );
+ wp_enqueue_script( 'newspack-wizards' );
+ }
+}
diff --git a/src/wizards/index.tsx b/src/wizards/index.tsx
index 7e23bf9041..bbf7f51e32 100644
--- a/src/wizards/index.tsx
+++ b/src/wizards/index.tsx
@@ -60,6 +60,14 @@ const components: Record< string, any > = {
label: __( 'Premium newsletters', 'newspack-plugin' ),
component: lazy( () => import( /* webpackChunkName: "newsletters-wizards" */ './newsletters/views/premium-newsletters' ) ),
},
+ 'newspack-rolling-content': {
+ label: __( 'Rolling Content', 'newspack-plugin' ),
+ component: lazy( () => import( /* webpackChunkName: "rolling-content-wizards" */ './rollingContent/views/all' ) ),
+ },
+ 'newspack-rolling-content-add': {
+ label: __( 'Add Rolling Content', 'newspack-plugin' ),
+ component: lazy( () => import( /* webpackChunkName: "rolling-content-wizards" */ './rollingContent/views/add' ) ),
+ },
} as const;
// Conditionally add the Audience Integrations page if the feature is enabled.
diff --git a/src/wizards/rollingContent/components/status-pill.tsx b/src/wizards/rollingContent/components/status-pill.tsx
new file mode 100644
index 0000000000..4d331ca61e
--- /dev/null
+++ b/src/wizards/rollingContent/components/status-pill.tsx
@@ -0,0 +1,28 @@
+/**
+ * Rolling Content demo — shared status indicator.
+ *
+ * Renders a small icon + label for an item's status. Each caller passes its
+ * own labels and icons keyed by its status union.
+ */
+
+/**
+ * WordPress dependencies
+ */
+import { Icon } from '@wordpress/components';
+
+export default function StatusPill< S extends string >( {
+ status,
+ labels,
+ icons,
+}: {
+ status: S;
+ labels: Record< S, string >;
+ icons: Record< S, JSX.Element >;
+} ) {
+ return (
+
+
+ { labels[ status ] }
+
+ );
+}
diff --git a/src/wizards/rollingContent/data.ts b/src/wizards/rollingContent/data.ts
new file mode 100644
index 0000000000..4285bc5fbc
--- /dev/null
+++ b/src/wizards/rollingContent/data.ts
@@ -0,0 +1,132 @@
+/**
+ * Rolling Content demo — fake in-memory dataset.
+ *
+ * 30 rolling contents, each with 5-25 deterministic entries. All values are
+ * stable across renders (no Math.random, no Date.now at module scope).
+ */
+
+const ROLLING_TITLES: string[] = [
+ 'Election Night 2026: Live Updates',
+ 'City Council Budget Hearings',
+ 'Wildfire Response Coverage',
+ 'School Board Recall Effort',
+ 'Hurricane Season Live Coverage',
+ 'Mayoral Debate Aftermath',
+ 'Transit Strike Day Two',
+ 'Federal Inquiry Hearings',
+ 'Stadium Vote Live',
+ 'Climate Protest Coverage',
+ 'Tech Company Layoffs Tracker',
+ 'Inauguration Day',
+ 'Special Session Coverage',
+ 'Tax Reform Town Halls',
+ 'Water Restriction Updates',
+ 'Election Recount Results',
+ 'Public Health Emergency Briefings',
+ 'Budget Override Vote',
+ 'Police Reform Hearings',
+ 'Bond Measure Election Night',
+ 'School District Negotiations',
+ 'Housing Crisis Forum',
+ 'Power Grid Outage Updates',
+ 'Recall Petition Tracker',
+ 'City Charter Vote',
+ 'Special Counsel Updates',
+ 'Park District Vote',
+ 'Casino Referendum Coverage',
+ 'Voter Roll Audit Hearings',
+ 'Annexation Vote Live',
+];
+
+const ENTRY_TITLES: string[] = [
+ 'Polls close in District 4',
+ 'Mayor responds to opposition statement',
+ 'Vote count delayed by ballot challenge',
+ 'Lawmakers reach tentative deal',
+ 'Live update: returns from precinct 12',
+ 'Statement from city attorney',
+ 'Crowd gathers outside city hall',
+ 'Press conference scheduled for 8 PM',
+ 'New witness called to testify',
+ 'Council moves into closed session',
+ 'Update: highway reopens to traffic',
+ 'Officials confirm timeline for vote',
+ 'Spokesperson denies allegations',
+ 'Crowd estimate revised upward',
+ 'Decision expected by tomorrow morning',
+];
+
+const AUTHORS: string[] = [ 'Alex Rivera', 'Jamie Chen', 'Morgan Patel', 'Sam Johnson', 'Riley Cooper', 'Jordan Kim' ];
+
+const TAGS: string[] = [ 'breaking', 'politics', 'local', 'national', 'elections', 'transit', 'weather', 'courts', 'education', 'climate' ];
+
+const BASE_DATE = new Date( '2026-05-01T12:00:00Z' );
+
+function makeDate( seed: number ): string {
+ const dayOffset = ( seed * 17 ) % 90;
+ const d = new Date( BASE_DATE );
+ d.setDate( d.getDate() - dayOffset );
+ return d.toISOString();
+}
+
+function entryStatusFor( id: number ): EntryStatus {
+ const statusRoll = ( id * 13 ) % 100;
+ if ( statusRoll < 70 ) {
+ return 'published';
+ }
+ if ( statusRoll < 90 ) {
+ return 'draft';
+ }
+ return 'scheduled';
+}
+
+function rollingStatusFor( idx: number ): RollingContentStatus {
+ if ( idx < 12 ) {
+ return 'active';
+ }
+ if ( idx < 22 ) {
+ return 'archived';
+ }
+ return 'scheduled';
+}
+
+function makeEntries( parentId: number, count: number ): Entry[] {
+ const entries: Entry[] = [];
+ for ( let i = 0; i < count; i++ ) {
+ const id = parentId * 1000 + i;
+ const status: EntryStatus = entryStatusFor( id );
+
+ const tagCount = 1 + ( id % 3 );
+ const tags: string[] = [];
+ for ( let t = 0; t < tagCount; t++ ) {
+ tags.push( TAGS[ ( id * 7 + t * 11 ) % TAGS.length ] );
+ }
+
+ entries.push( {
+ id,
+ title: ENTRY_TITLES[ ( id * 7 ) % ENTRY_TITLES.length ],
+ date: makeDate( id ),
+ author: AUTHORS[ id % AUTHORS.length ],
+ featuredImage: `https://picsum.photos/seed/entry-${ id }/200/120`,
+ status,
+ tags,
+ } );
+ }
+ return entries;
+}
+
+export const ROLLING_CONTENTS: RollingContent[] = ROLLING_TITLES.map( ( title, idx ) => {
+ const id = idx + 1;
+ const status: RollingContentStatus = rollingStatusFor( idx );
+ const entryCount = 5 + ( ( id * 11 ) % 21 );
+ return {
+ id,
+ title,
+ date: makeDate( id ),
+ featuredImage: `https://picsum.photos/seed/rolling-${ id }/200/120`,
+ status,
+ entries: makeEntries( id, entryCount ),
+ };
+} );
+
+export const getRollingContent = ( id: number ): RollingContent | undefined => ROLLING_CONTENTS.find( r => r.id === id );
diff --git a/src/wizards/rollingContent/modals/add-entry-info.tsx b/src/wizards/rollingContent/modals/add-entry-info.tsx
new file mode 100644
index 0000000000..f90e76d38f
--- /dev/null
+++ b/src/wizards/rollingContent/modals/add-entry-info.tsx
@@ -0,0 +1,40 @@
+/**
+ * Rolling Content demo — Add Entry info modal.
+ *
+ * Explains the implicit parent association: in a real implementation,
+ * choosing "Add New Entry" from a Rolling Content row (or from inside the
+ * Manage Entries modal) would open the block editor with the chosen
+ * Rolling Content preselected as the parent.
+ */
+
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { Button, Modal } from '@wordpress/components';
+import { createInterpolateElement } from '@wordpress/element';
+
+export default function AddEntryInfoModal( { parentTitle, onClose }: { parentTitle: string; onClose: () => void } ) {
+ return (
+
+
+ { createInterpolateElement(
+ sprintf(
+ /* translators: %s: parent rolling content title, wrapped in . */
+ __(
+ 'Selecting "Add New Entry" would open the post editor with %s preselected as the parent. The link between an entry and its parent is implicit in how you enter the editor — no manual selection required.',
+ 'newspack-plugin'
+ ),
+ parentTitle
+ ),
+ { name: }
+ ) }
+