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.