table-of-contents.php

I’ll build this as a powerful reusable Table of Contents component for your RX theme, designed to work from template-parts/components/table-of-contents.php and be called from posts/pages/templates.

Friend, create this file:

rx-theme/template-parts/components/table-of-contents.php

This component includes: automatic heading scan, H2-H6 support, custom title, min heading count, nested/flat mode, sticky mode, collapsible mode, active scroll highlight, smooth scroll, mobile support, reading progress, copy link button, back-to-top link, accessibility labels, custom classes, excluded headings, fallback mode, shortcode-safe content parsing, and automatic heading ID generation by JavaScript.

<?php
/**
 * RX Theme - Advanced Table of Contents Component
 *
 * File: template-parts/components/table-of-contents.php
 *
 * Usage:
 * get_template_part(
 *     'template-parts/components/table-of-contents',
 *     null,
 *     array(
 *         'title'              => 'Table of Contents',
 *         'content'            => get_the_content(),
 *         'heading_levels'     => array( 2, 3, 4 ),
 *         'min_headings'       => 2,
 *         'collapsible'        => true,
 *         'collapsed'          => false,
 *         'sticky'             => true,
 *         'show_numbers'       => true,
 *         'show_progress'      => true,
 *         'show_back_to_top'   => true,
 *         'show_copy_buttons'  => false,
 *         'auto_heading_ids'   => true,
 *         'nested'             => true,
 *         'target_selector'    => '.entry-content',
 *         'exclude_text'       => array( 'FAQs', 'References' ),
 *         'exclude_classes'    => array( 'no-toc', 'rx-no-toc' ),
 *     )
 * );
 *
 * @package RX_Theme
 */

defined( 'ABSPATH' ) || exit;

$post_id = get_the_ID();

/**
 * ------------------------------------------------------------
 * Default component arguments
 * ------------------------------------------------------------
 */
$defaults = array(
	'id'                    => 'rx-toc-' . absint( $post_id ),
	'title'                 => __( 'Table of Contents', 'rx-theme' ),
	'content'               => '',
	'heading_levels'        => array( 2, 3, 4, 5, 6 ),
	'min_headings'          => 2,
	'max_items'             => 120,

	// Display.
	'nested'                => true,
	'collapsible'           => true,
	'collapsed'             => false,
	'sticky'                => false,
	'floating'              => false,
	'show_numbers'          => true,
	'show_icons'            => true,
	'show_progress'         => true,
	'show_back_to_top'      => true,
	'show_copy_buttons'     => false,
	'show_heading_count'    => true,

	// Behavior.
	'smooth_scroll'         => true,
	'scroll_offset'         => 96,
	'active_highlight'      => true,
	'auto_heading_ids'      => true,
	'open_current_branch'   => true,

	// Selectors.
	'target_selector'       => '.entry-content',
	'toc_container_class'   => '',
	'list_class'            => '',
	'item_class'            => '',
	'link_class'            => '',

	// Exclusion.
	'exclude_text'          => array(),
	'exclude_classes'       => array( 'no-toc', 'rx-no-toc', 'screen-reader-text' ),
	'exclude_selectors'     => array( '.no-toc', '.rx-no-toc', '.screen-reader-text' ),

	// Accessibility.
	'aria_label'            => __( 'Article table of contents', 'rx-theme' ),
	'toggle_label_open'     => __( 'Show table of contents', 'rx-theme' ),
	'toggle_label_close'    => __( 'Hide table of contents', 'rx-theme' ),

	// Fallback.
	'fallback_message'      => '',
	'echo_fallback'         => false,

	// Extra.
	'before'                => '',
	'after'                 => '',
);

$args = isset( $args ) && is_array( $args ) ? wp_parse_args( $args, $defaults ) : $defaults;

/**
 * ------------------------------------------------------------
 * Helper: sanitize heading levels
 * ------------------------------------------------------------
 */
$heading_levels = array();

foreach ( (array) $args['heading_levels'] as $level ) {
	$level = absint( $level );

	if ( $level >= 2 && $level <= 6 ) {
		$heading_levels[] = $level;
	}
}

$heading_levels = array_values( array_unique( $heading_levels ) );

if ( empty( $heading_levels ) ) {
	$heading_levels = array( 2, 3, 4, 5, 6 );
}

/**
 * ------------------------------------------------------------
 * Helper: create a safe anchor ID
 * ------------------------------------------------------------
 */
if ( ! function_exists( 'rx_theme_toc_create_anchor_id' ) ) {
	/**
	 * Create clean anchor ID from heading text.
	 *
	 * @param string $text Heading text.
	 * @param array  $used Used IDs.
	 * @return string
	 */
	function rx_theme_toc_create_anchor_id( $text, &$used = array() ) {
		$text = wp_strip_all_tags( html_entity_decode( (string) $text, ENT_QUOTES, get_bloginfo( 'charset' ) ) );
		$text = remove_accents( $text );
		$text = strtolower( $text );
		$text = preg_replace( '/[^a-z0-9\s\-_]/', '', $text );
		$text = preg_replace( '/[\s_]+/', '-', $text );
		$text = trim( $text, '-' );

		if ( '' === $text ) {
			$text = 'section';
		}

		$base = $text;
		$i    = 2;

		while ( in_array( $text, $used, true ) ) {
			$text = $base . '-' . $i;
			$i++;
		}

		$used[] = $text;

		return sanitize_html_class( $text );
	}
}

/**
 * ------------------------------------------------------------
 * Helper: check excluded heading text
 * ------------------------------------------------------------
 */
if ( ! function_exists( 'rx_theme_toc_is_excluded_text' ) ) {
	/**
	 * Determine whether a heading text should be excluded.
	 *
	 * @param string $text Heading text.
	 * @param array  $exclude_text Excluded text list.
	 * @return bool
	 */
	function rx_theme_toc_is_excluded_text( $text, $exclude_text = array() ) {
		$text = trim( wp_strip_all_tags( (string) $text ) );

		if ( '' === $text || empty( $exclude_text ) ) {
			return false;
		}

		foreach ( (array) $exclude_text as $excluded ) {
			$excluded = trim( (string) $excluded );

			if ( '' === $excluded ) {
				continue;
			}

			if ( 0 === strcasecmp( $text, $excluded ) ) {
				return true;
			}
		}

		return false;
	}
}

/**
 * ------------------------------------------------------------
 * Helper: check excluded heading classes
 * ------------------------------------------------------------
 */
if ( ! function_exists( 'rx_theme_toc_has_excluded_class' ) ) {
	/**
	 * Determine whether a heading has an excluded class.
	 *
	 * @param string $attributes Heading attributes.
	 * @param array  $exclude_classes Excluded class names.
	 * @return bool
	 */
	function rx_theme_toc_has_excluded_class( $attributes, $exclude_classes = array() ) {
		if ( empty( $attributes ) || empty( $exclude_classes ) ) {
			return false;
		}

		if ( ! preg_match( '/class=["\']([^"\']+)["\']/i', $attributes, $match ) ) {
			return false;
		}

		$classes = preg_split( '/\s+/', trim( $match[1] ) );

		foreach ( (array) $exclude_classes as $excluded_class ) {
			if ( in_array( $excluded_class, $classes, true ) ) {
				return true;
			}
		}

		return false;
	}
}

/**
 * ------------------------------------------------------------
 * Content source
 * ------------------------------------------------------------
 */
$content = (string) $args['content'];

if ( '' === trim( $content ) && $post_id ) {
	$post_obj = get_post( $post_id );

	if ( $post_obj instanceof WP_Post ) {
		$content = (string) $post_obj->post_content;
	}
}

/**
 * Apply content filters lightly so blocks and shortcodes become real HTML.
 * Avoid echoing this content here; this component only reads headings.
 */
$content_for_scan = $content;

if ( has_blocks( $content_for_scan ) ) {
	$content_for_scan = do_blocks( $content_for_scan );
}

$content_for_scan = do_shortcode( $content_for_scan );

/**
 * ------------------------------------------------------------
 * Extract headings
 * ------------------------------------------------------------
 */
$levels_pattern = implode( '|', array_map( 'absint', $heading_levels ) );
$headings       = array();
$used_ids       = array();

if ( preg_match_all( '/<h(' . $levels_pattern . ')([^>]*)>(.*?)<\/h\1>/is', $content_for_scan, $matches, PREG_SET_ORDER ) ) {
	foreach ( $matches as $match ) {
		if ( count( $headings ) >= absint( $args['max_items'] ) ) {
			break;
		}

		$level      = absint( $match[1] );
		$attributes = isset( $match[2] ) ? (string) $match[2] : '';
		$inner_html = isset( $match[3] ) ? (string) $match[3] : '';
		$text       = trim( wp_strip_all_tags( $inner_html ) );

		if ( '' === $text ) {
			continue;
		}

		if ( rx_theme_toc_is_excluded_text( $text, (array) $args['exclude_text'] ) ) {
			continue;
		}

		if ( rx_theme_toc_has_excluded_class( $attributes, (array) $args['exclude_classes'] ) ) {
			continue;
		}

		$id = '';

		if ( preg_match( '/id=["\']([^"\']+)["\']/i', $attributes, $id_match ) ) {
			$id = sanitize_html_class( $id_match[1] );
		}

		if ( '' === $id ) {
			$id = rx_theme_toc_create_anchor_id( $text, $used_ids );
		} else {
			$used_ids[] = $id;
		}

		$headings[] = array(
			'id'    => $id,
			'level' => $level,
			'text'  => $text,
		);
	}
}

$heading_count = count( $headings );

if ( $heading_count < absint( $args['min_headings'] ) ) {
	if ( ! empty( $args['echo_fallback'] ) && ! empty( $args['fallback_message'] ) ) {
		echo '<div class="rx-toc-fallback">' . esc_html( $args['fallback_message'] ) . '</div>';
	}

	return;
}

/**
 * ------------------------------------------------------------
 * CSS classes
 * ------------------------------------------------------------
 */
$toc_id = sanitize_html_class( $args['id'] );

$container_classes = array(
	'rx-toc',
	'rx-table-of-contents',
);

if ( ! empty( $args['sticky'] ) ) {
	$container_classes[] = 'is-sticky';
}

if ( ! empty( $args['floating'] ) ) {
	$container_classes[] = 'is-floating';
}

if ( ! empty( $args['collapsible'] ) ) {
	$container_classes[] = 'is-collapsible';
}

if ( ! empty( $args['collapsed'] ) ) {
	$container_classes[] = 'is-collapsed';
}

if ( ! empty( $args['show_numbers'] ) ) {
	$container_classes[] = 'has-numbers';
}

if ( ! empty( $args['show_progress'] ) ) {
	$container_classes[] = 'has-progress';
}

if ( ! empty( $args['toc_container_class'] ) ) {
	$extra_classes = preg_split( '/\s+/', (string) $args['toc_container_class'] );

	foreach ( $extra_classes as $class ) {
		$class = sanitize_html_class( $class );

		if ( $class ) {
			$container_classes[] = $class;
		}
	}
}

$container_classes = implode( ' ', array_map( 'sanitize_html_class', array_unique( $container_classes ) ) );

$list_class = 'rx-toc__list';

if ( ! empty( $args['list_class'] ) ) {
	$list_class .= ' ' . sanitize_html_class( $args['list_class'] );
}

$item_class = 'rx-toc__item';

if ( ! empty( $args['item_class'] ) ) {
	$item_class .= ' ' . sanitize_html_class( $args['item_class'] );
}

$link_class = 'rx-toc__link';

if ( ! empty( $args['link_class'] ) ) {
	$link_class .= ' ' . sanitize_html_class( $args['link_class'] );
}

/**
 * ------------------------------------------------------------
 * Render list
 * ------------------------------------------------------------
 */
if ( ! function_exists( 'rx_theme_toc_render_flat_list' ) ) {
	/**
	 * Render flat TOC list.
	 *
	 * @param array  $headings Headings.
	 * @param string $list_class List class.
	 * @param string $item_class Item class.
	 * @param string $link_class Link class.
	 * @param bool   $show_copy_buttons Show copy buttons.
	 */
	function rx_theme_toc_render_flat_list( $headings, $list_class, $item_class, $link_class, $show_copy_buttons = false ) {
		echo '<ol class="' . esc_attr( $list_class ) . '">';

		foreach ( $headings as $index => $heading ) {
			$item_classes = array(
				$item_class,
				'rx-toc__item--level-' . absint( $heading['level'] ),
			);

			echo '<li class="' . esc_attr( implode( ' ', $item_classes ) ) . '" data-rx-toc-level="' . esc_attr( $heading['level'] ) . '">';

			echo '<a class="' . esc_attr( $link_class ) . '" href="#' . esc_attr( $heading['id'] ) . '" data-rx-toc-link data-rx-toc-target="' . esc_attr( $heading['id'] ) . '">';
			echo '<span class="rx-toc__number" aria-hidden="true">' . esc_html( $index + 1 ) . '</span>';
			echo '<span class="rx-toc__text">' . esc_html( $heading['text'] ) . '</span>';
			echo '</a>';

			if ( $show_copy_buttons ) {
				echo '<button class="rx-toc__copy" type="button" data-rx-copy-link="#' . esc_attr( $heading['id'] ) . '" aria-label="' . esc_attr__( 'Copy section link', 'rx-theme' ) . '">';
				echo '<span aria-hidden="true">#</span>';
				echo '</button>';
			}

			echo '</li>';
		}

		echo '</ol>';
	}
}

if ( ! function_exists( 'rx_theme_toc_render_nested_list' ) ) {
	/**
	 * Render nested TOC list.
	 *
	 * @param array  $headings Headings.
	 * @param string $list_class List class.
	 * @param string $item_class Item class.
	 * @param string $link_class Link class.
	 * @param bool   $show_copy_buttons Show copy buttons.
	 */
	function rx_theme_toc_render_nested_list( $headings, $list_class, $item_class, $link_class, $show_copy_buttons = false ) {
		if ( empty( $headings ) ) {
			return;
		}

		$current_level = 0;
		$first         = true;

		foreach ( $headings as $index => $heading ) {
			$level = absint( $heading['level'] );

			if ( $first ) {
				echo '<ol class="' . esc_attr( $list_class ) . '">';
				$current_level = $level;
				$first         = false;
			}

			if ( $level > $current_level ) {
				while ( $level > $current_level ) {
					echo '<ol class="rx-toc__sublist">';
					$current_level++;
				}
			} elseif ( $level < $current_level ) {
				while ( $level < $current_level ) {
					echo '</li></ol>';
					$current_level--;
				}
				echo '</li>';
			} elseif ( 0 !== $index ) {
				echo '</li>';
			}

			$item_classes = array(
				$item_class,
				'rx-toc__item--level-' . $level,
			);

			echo '<li class="' . esc_attr( implode( ' ', $item_classes ) ) . '" data-rx-toc-level="' . esc_attr( $level ) . '">';

			echo '<a class="' . esc_attr( $link_class ) . '" href="#' . esc_attr( $heading['id'] ) . '" data-rx-toc-link data-rx-toc-target="' . esc_attr( $heading['id'] ) . '">';
			echo '<span class="rx-toc__number" aria-hidden="true">' . esc_html( $index + 1 ) . '</span>';
			echo '<span class="rx-toc__text">' . esc_html( $heading['text'] ) . '</span>';
			echo '</a>';

			if ( $show_copy_buttons ) {
				echo '<button class="rx-toc__copy" type="button" data-rx-copy-link="#' . esc_attr( $heading['id'] ) . '" aria-label="' . esc_attr__( 'Copy section link', 'rx-theme' ) . '">';
				echo '<span aria-hidden="true">#</span>';
				echo '</button>';
			}
		}

		while ( $current_level > 0 ) {
			echo '</li></ol>';
			$current_level--;
		}
	}
}

echo wp_kses_post( $args['before'] );
?>

<nav
	id="<?php echo esc_attr( $toc_id ); ?>"
	class="<?php echo esc_attr( $container_classes ); ?>"
	aria-label="<?php echo esc_attr( $args['aria_label'] ); ?>"
	data-rx-toc
	data-rx-toc-target-selector="<?php echo esc_attr( $args['target_selector'] ); ?>"
	data-rx-toc-scroll-offset="<?php echo esc_attr( absint( $args['scroll_offset'] ) ); ?>"
	data-rx-toc-smooth-scroll="<?php echo ! empty( $args['smooth_scroll'] ) ? 'true' : 'false'; ?>"
	data-rx-toc-active-highlight="<?php echo ! empty( $args['active_highlight'] ) ? 'true' : 'false'; ?>"
	data-rx-toc-auto-heading-ids="<?php echo ! empty( $args['auto_heading_ids'] ) ? 'true' : 'false'; ?>"
	data-rx-toc-open-current-branch="<?php echo ! empty( $args['open_current_branch'] ) ? 'true' : 'false'; ?>"
>
	<?php if ( ! empty( $args['show_progress'] ) ) : ?>
		<div class="rx-toc__progress" aria-hidden="true">
			<span class="rx-toc__progress-bar" data-rx-toc-progress-bar></span>
		</div>
	<?php endif; ?>

	<div class="rx-toc__header">
		<div class="rx-toc__title-wrap">
			<?php if ( ! empty( $args['show_icons'] ) ) : ?>
				<span class="rx-toc__icon" aria-hidden="true"></span>
			<?php endif; ?>

			<strong class="rx-toc__title">
				<?php echo esc_html( $args['title'] ); ?>
			</strong>

			<?php if ( ! empty( $args['show_heading_count'] ) ) : ?>
				<span class="rx-toc__count">
					<?php echo esc_html( sprintf( _n( '%s section', '%s sections', $heading_count, 'rx-theme' ), number_format_i18n( $heading_count ) ) ); ?>
				</span>
			<?php endif; ?>
		</div>

		<?php if ( ! empty( $args['collapsible'] ) ) : ?>
			<button
				class="rx-toc__toggle"
				type="button"
				aria-controls="<?php echo esc_attr( $toc_id ); ?>-body"
				aria-expanded="<?php echo ! empty( $args['collapsed'] ) ? 'false' : 'true'; ?>"
				data-rx-toc-toggle
				data-label-open="<?php echo esc_attr( $args['toggle_label_open'] ); ?>"
				data-label-close="<?php echo esc_attr( $args['toggle_label_close'] ); ?>"
			>
				<span class="rx-toc__toggle-text">
					<?php echo ! empty( $args['collapsed'] ) ? esc_html( $args['toggle_label_open'] ) : esc_html( $args['toggle_label_close'] ); ?>
				</span>
				<span class="rx-toc__toggle-icon" aria-hidden="true"></span>
			</button>
		<?php endif; ?>
	</div>

	<div
		id="<?php echo esc_attr( $toc_id ); ?>-body"
		class="rx-toc__body"
		<?php echo ! empty( $args['collapsed'] ) ? 'hidden' : ''; ?>
	>
		<?php
		if ( ! empty( $args['nested'] ) ) {
			rx_theme_toc_render_nested_list(
				$headings,
				$list_class,
				$item_class,
				$link_class,
				(bool) $args['show_copy_buttons']
			);
		} else {
			rx_theme_toc_render_flat_list(
				$headings,
				$list_class,
				$item_class,
				$link_class,
				(bool) $args['show_copy_buttons']
			);
		}
		?>

		<?php if ( ! empty( $args['show_back_to_top'] ) ) : ?>
			<a class="rx-toc__back-to-top" href="#top" data-rx-toc-top>
				<?php esc_html_e( 'Back to top', 'rx-theme' ); ?>
			</a>
		<?php endif; ?>
	</div>
</nav>

<style>
	.rx-toc {
		--rx-toc-border: rgba(0, 0, 0, .12);
		--rx-toc-bg: #fff;
		--rx-toc-muted: #667085;
		--rx-toc-text: #101828;
		--rx-toc-accent: #2563eb;
		--rx-toc-radius: 14px;
		--rx-toc-shadow: 0 10px 30px rgba(15, 23, 42, .08);

		position: relative;
		margin: 24px 0;
		padding: 0;
		border: 1px solid var(--rx-toc-border);
		border-radius: var(--rx-toc-radius);
		background: var(--rx-toc-bg);
		color: var(--rx-toc-text);
		box-shadow: var(--rx-toc-shadow);
		overflow: hidden;
	}

	.rx-toc.is-sticky {
		position: sticky;
		top: 24px;
		z-index: 10;
	}

	.rx-toc.is-floating {
		max-width: 360px;
	}

	.rx-toc__progress {
		position: absolute;
		top: 0;
		left: 0;
		right: 0;
		height: 4px;
		background: rgba(37, 99, 235, .12);
		overflow: hidden;
	}

	.rx-toc__progress-bar {
		display: block;
		width: 0%;
		height: 100%;
		background: var(--rx-toc-accent);
		transition: width .15s linear;
	}

	.rx-toc__header {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 12px;
		padding: 18px 18px 14px;
		border-bottom: 1px solid var(--rx-toc-border);
	}

	.rx-toc.has-progress .rx-toc__header {
		padding-top: 22px;
	}

	.rx-toc__title-wrap {
		display: flex;
		align-items: center;
		flex-wrap: wrap;
		gap: 8px;
		min-width: 0;
	}

	.rx-toc__icon {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: 28px;
		height: 28px;
		border-radius: 999px;
		background: rgba(37, 99, 235, .1);
		color: var(--rx-toc-accent);
		font-size: 15px;
		line-height: 1;
	}

	.rx-toc__title {
		font-size: 17px;
		font-weight: 700;
		line-height: 1.3;
	}

	.rx-toc__count {
		color: var(--rx-toc-muted);
		font-size: 13px;
		line-height: 1.4;
	}

	.rx-toc__toggle {
		display: inline-flex;
		align-items: center;
		gap: 6px;
		border: 1px solid var(--rx-toc-border);
		border-radius: 999px;
		background: transparent;
		color: var(--rx-toc-text);
		padding: 7px 11px;
		font-size: 13px;
		line-height: 1;
		cursor: pointer;
	}

	.rx-toc__toggle:hover,
	.rx-toc__toggle:focus {
		border-color: var(--rx-toc-accent);
		color: var(--rx-toc-accent);
		outline: none;
	}

	.rx-toc__toggle-icon {
		transition: transform .2s ease;
	}

	.rx-toc.is-collapsed .rx-toc__toggle-icon {
		transform: rotate(-90deg);
	}

	.rx-toc__body {
		padding: 14px 18px 18px;
	}

	.rx-toc__list,
	.rx-toc__sublist {
		margin: 0;
		padding-left: 0;
		list-style: none;
	}

	.rx-toc__sublist {
		margin-top: 4px;
		margin-left: 16px;
		padding-left: 12px;
		border-left: 1px dashed var(--rx-toc-border);
	}

	.rx-toc__item {
		position: relative;
		margin: 0;
		padding: 0;
	}

	.rx-toc__item + .rx-toc__item {
		margin-top: 4px;
	}

	.rx-toc__link {
		display: flex;
		align-items: flex-start;
		gap: 8px;
		padding: 7px 8px;
		border-radius: 10px;
		color: var(--rx-toc-text);
		font-size: 14px;
		line-height: 1.45;
		text-decoration: none;
		transition: background-color .15s ease, color .15s ease;
	}

	.rx-toc__link:hover,
	.rx-toc__link:focus {
		background: rgba(37, 99, 235, .08);
		color: var(--rx-toc-accent);
		outline: none;
	}

	.rx-toc__link.is-active {
		background: rgba(37, 99, 235, .12);
		color: var(--rx-toc-accent);
		font-weight: 700;
	}

	.rx-toc__number {
		display: none;
		flex: 0 0 auto;
		min-width: 22px;
		color: var(--rx-toc-muted);
		font-size: 12px;
		font-weight: 600;
		line-height: 1.7;
		text-align: right;
	}

	.rx-toc.has-numbers .rx-toc__number {
		display: inline-block;
	}

	.rx-toc__text {
		min-width: 0;
		overflow-wrap: anywhere;
	}

	.rx-toc__copy {
		position: absolute;
		top: 6px;
		right: 4px;
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: 24px;
		height: 24px;
		border: 0;
		border-radius: 999px;
		background: transparent;
		color: var(--rx-toc-muted);
		cursor: pointer;
		opacity: 0;
		transition: opacity .15s ease, background-color .15s ease, color .15s ease;
	}

	.rx-toc__item:hover > .rx-toc__copy,
	.rx-toc__copy:focus {
		opacity: 1;
	}

	.rx-toc__copy:hover,
	.rx-toc__copy:focus {
		background: rgba(37, 99, 235, .1);
		color: var(--rx-toc-accent);
		outline: none;
	}

	.rx-toc__back-to-top {
		display: inline-flex;
		margin-top: 14px;
		padding: 8px 10px;
		border-radius: 999px;
		background: rgba(37, 99, 235, .08);
		color: var(--rx-toc-accent);
		font-size: 13px;
		font-weight: 700;
		text-decoration: none;
	}

	.rx-toc__back-to-top:hover,
	.rx-toc__back-to-top:focus {
		background: rgba(37, 99, 235, .14);
		outline: none;
	}

	@media (max-width: 768px) {
		.rx-toc {
			border-radius: 12px;
		}

		.rx-toc.is-sticky {
			position: relative;
			top: auto;
		}

		.rx-toc__header {
			align-items: flex-start;
			flex-direction: column;
		}

		.rx-toc__toggle {
			width: 100%;
			justify-content: center;
		}

		.rx-toc__body {
			max-height: 60vh;
			overflow: auto;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.rx-toc *,
		.rx-toc *::before,
		.rx-toc *::after {
			scroll-behavior: auto !important;
			transition: none !important;
		}
	}
</style>

<script>
(function () {
	'use strict';

	function ready(callback) {
		if (document.readyState !== 'loading') {
			callback();
		} else {
			document.addEventListener('DOMContentLoaded', callback);
		}
	}

	function cleanText(text) {
		return String(text || '')
			.toLowerCase()
			.normalize('NFD')
			.replace(/[\u0300-\u036f]/g, '')
			.replace(/[^a-z0-9\s\-_]/g, '')
			.replace(/[\s_]+/g, '-')
			.replace(/^-+|-+$/g, '') || 'section';
	}

	function uniqueId(base, used) {
		var id = base;
		var i = 2;

		while (used[id] || document.getElementById(id)) {
			id = base + '-' + i;
			i++;
		}

		used[id] = true;

		return id;
	}

	function getOffset(toc) {
		var value = parseInt(toc.getAttribute('data-rx-toc-scroll-offset') || '96', 10);
		return isNaN(value) ? 96 : value;
	}

	function scrollToTarget(target, offset, smooth) {
		var top = target.getBoundingClientRect().top + window.pageYOffset - offset;

		window.scrollTo({
			top: top,
			behavior: smooth ? 'smooth' : 'auto'
		});
	}

	function updateUrlHash(id) {
		if (!id || !history.replaceState) {
			return;
		}

		history.replaceState(null, '', '#' + encodeURIComponent(id));
	}

	function initToc(toc) {
		var targetSelector = toc.getAttribute('data-rx-toc-target-selector') || '.entry-content';
		var content = document.querySelector(targetSelector);
		var smooth = toc.getAttribute('data-rx-toc-smooth-scroll') === 'true';
		var activeHighlight = toc.getAttribute('data-rx-toc-active-highlight') === 'true';
		var autoHeadingIds = toc.getAttribute('data-rx-toc-auto-heading-ids') === 'true';
		var offset = getOffset(toc);
		var used = {};
		var links = Array.prototype.slice.call(toc.querySelectorAll('[data-rx-toc-link]'));
		var toggle = toc.querySelector('[data-rx-toc-toggle]');
		var body = toc.querySelector('.rx-toc__body');
		var progressBar = toc.querySelector('[data-rx-toc-progress-bar]');

		if (!content || !links.length) {
			return;
		}

		if (autoHeadingIds) {
			var headings = Array.prototype.slice.call(content.querySelectorAll('h2,h3,h4,h5,h6'));

			headings.forEach(function (heading) {
				var skip =
					heading.classList.contains('no-toc') ||
					heading.classList.contains('rx-no-toc') ||
					heading.classList.contains('screen-reader-text');

				if (skip) {
					return;
				}

				if (!heading.id) {
					heading.id = uniqueId(cleanText(heading.textContent), used);
				}
			});
		}

		links.forEach(function (link) {
			link.addEventListener('click', function (event) {
				var href = link.getAttribute('href');

				if (!href || href.charAt(0) !== '#') {
					return;
				}

				var id = decodeURIComponent(href.slice(1));
				var target = document.getElementById(id);

				if (!target) {
					return;
				}

				event.preventDefault();
				scrollToTarget(target, offset, smooth);
				updateUrlHash(id);
			});
		});

		if (toggle && body) {
			toggle.addEventListener('click', function () {
				var expanded = toggle.getAttribute('aria-expanded') === 'true';
				var openLabel = toggle.getAttribute('data-label-open') || 'Show table of contents';
				var closeLabel = toggle.getAttribute('data-label-close') || 'Hide table of contents';
				var text = toggle.querySelector('.rx-toc__toggle-text');

				toggle.setAttribute('aria-expanded', expanded ? 'false' : 'true');
				body.hidden = expanded;
				toc.classList.toggle('is-collapsed', expanded);

				if (text) {
					text.textContent = expanded ? openLabel : closeLabel;
				}
			});
		}

		var copyButtons = Array.prototype.slice.call(toc.querySelectorAll('[data-rx-copy-link]'));

		copyButtons.forEach(function (button) {
			button.addEventListener('click', function () {
				var hash = button.getAttribute('data-rx-copy-link') || '';
				var url = window.location.origin + window.location.pathname + hash;

				if (navigator.clipboard && navigator.clipboard.writeText) {
					navigator.clipboard.writeText(url).then(function () {
						button.classList.add('is-copied');
						setTimeout(function () {
							button.classList.remove('is-copied');
						}, 1200);
					});
				}
			});
		});

		var topLink = toc.querySelector('[data-rx-toc-top]');

		if (topLink) {
			topLink.addEventListener('click', function (event) {
				event.preventDefault();

				window.scrollTo({
					top: 0,
					behavior: smooth ? 'smooth' : 'auto'
				});
			});
		}

		function updateProgress() {
			if (!progressBar) {
				return;
			}

			var rect = content.getBoundingClientRect();
			var contentTop = rect.top + window.pageYOffset;
			var contentHeight = content.offsetHeight;
			var current = window.pageYOffset + offset;
			var percent = ((current - contentTop) / Math.max(contentHeight - window.innerHeight, 1)) * 100;

			percent = Math.max(0, Math.min(100, percent));
			progressBar.style.width = percent + '%';
		}

		function updateActiveLink() {
			if (!activeHighlight) {
				return;
			}

			var currentId = '';
			var candidates = links
				.map(function (link) {
					var id = (link.getAttribute('href') || '').replace('#', '');
					var heading = document.getElementById(decodeURIComponent(id));

					return heading ? {
						id: id,
						link: link,
						top: heading.getBoundingClientRect().top
					} : null;
				})
				.filter(Boolean);

			candidates.forEach(function (item) {
				if (item.top <= offset + 6) {
					currentId = item.id;
				}
			});

			if (!currentId && candidates.length) {
				currentId = candidates[0].id;
			}

			links.forEach(function (link) {
				var hrefId = (link.getAttribute('href') || '').replace('#', '');
				var active = hrefId === currentId;
				var item = link.closest('.rx-toc__item');

				link.classList.toggle('is-active', active);

				if (item) {
					item.classList.toggle('is-active', active);
				}
			});
		}

		var ticking = false;

		function onScroll() {
			if (ticking) {
				return;
			}

			window.requestAnimationFrame(function () {
				updateProgress();
				updateActiveLink();
				ticking = false;
			});

			ticking = true;
		}

		window.addEventListener('scroll', onScroll, { passive: true });
		window.addEventListener('resize', onScroll);

		updateProgress();
		updateActiveLink();

		if (window.location.hash) {
			var initialTarget = document.getElementById(decodeURIComponent(window.location.hash.slice(1)));

			if (initialTarget) {
				setTimeout(function () {
					scrollToTarget(initialTarget, offset, false);
				}, 60);
			}
		}
	}

	ready(function () {
		Array.prototype.slice.call(document.querySelectorAll('[data-rx-toc]')).forEach(initToc);
	});
})();
</script>

<?php
echo wp_kses_post( $args['after'] );

Call it inside single.php, page.php, or your article template like this:

<?php
get_template_part(
	'template-parts/components/table-of-contents',
	null,
	array(
		'title'             => 'Table of Contents',
		'content'           => get_the_content(),
		'heading_levels'    => array( 2, 3, 4, 5, 6 ),
		'min_headings'      => 2,
		'collapsible'       => true,
		'collapsed'         => false,
		'sticky'            => true,
		'nested'            => true,
		'show_numbers'      => true,
		'show_progress'     => true,
		'show_back_to_top'  => true,
		'show_copy_buttons' => true,
		'auto_heading_ids'  => true,
		'target_selector'   => '.entry-content',
	)
);
?>

Best placement:

<article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>

	<header class="entry-header">
		<?php the_title( '<h1 class="entry-title">', '</h1>' ); ?>
	</header>

	<?php
	get_template_part(
		'template-parts/components/table-of-contents',
		null,
		array(
			'content'         => get_the_content(),
			'target_selector' => '.entry-content',
			'sticky'          => true,
		)
	);
	?>

	<div class="entry-content">
		<?php the_content(); ?>
	</div>

</article>

This file is safe to start using directly in your RX theme.

Leave a Reply

Your email address will not be published. Required fields are marked *