Component
Table of contents
A table of contents that follows the reading position, as a sticky column or a disclosure.
- #arc
- #component
- #toc
- #navigation
- #docs
- #reading
- #sticky
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/toc.jsonFiles
components/arc/toc/toc.module.csscomponents/arc/toc/toc.tsx
Registry items it pulls in
Source
View as Markdowntoc.module.csstoc.module.csstoc.module.csscss
.aside { position: sticky; inset-block-start: var(--space-20); display: flex; flex-direction: column; gap: var(--space-4); align-items: flex-start; }
.title { margin: 0 0 var(--space-3); color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--label-weight); }
.list { display: flex; margin: 0; padding: 0; flex-direction: column; list-style: none; border-inline-start: 2px solid var(--border); }
/* The rule runs down the whole list and the active link paints its own slice of it, so the mark sits on one line. */
.link { display: block; border-inline-start: 2px solid transparent; margin-inline-start: -2px; padding: var(--space-2) var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.5; text-decoration: none; word-break: keep-all; transition: color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard); }
.link[data-level="3"] { padding-inline-start: var(--space-6); }
.link[data-level="4"] { padding-inline-start: var(--space-10); }
.link[aria-current="true"] { border-inline-start-color: var(--accent); color: var(--accent); font-weight: 500; }
@media (hover: hover) and (pointer: fine) { .link:hover { color: var(--foreground); } .link[aria-current="true"]:hover { color: var(--accent); } }
.toTop { display: inline-flex; min-height: var(--control-height-sm); align-items: center; gap: var(--space-2); border: 0; border-radius: var(--radius-pill); padding: 0 var(--space-3); background: var(--surface-muted); color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: var(--label-weight); cursor: pointer; transition: background var(--duration-fast) var(--ease-standard); }
.toTop svg { flex: none; }
@media (hover: hover) and (pointer: fine) { .toTop:hover { background: var(--surface-sunken); } }
.toTop:active { background: var(--surface-sunken); }
.inline { border: 1px solid var(--border-subtle); border-radius: var(--radius-panel); background: var(--surface); overflow: hidden; }
.toggle { display: flex; width: 100%; min-height: var(--control-height-md); align-items: center; gap: var(--space-2); border: 0; padding: 0 var(--space-4); background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: var(--label-weight); text-align: start; cursor: pointer; }
.toggleLabel { flex: 1 1 auto; min-width: 0; }
.count { flex: none; color: var(--text-secondary); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.chevron { flex: none; color: var(--text-secondary); transition: transform var(--duration-fast) var(--ease-standard); }
.toggle[aria-expanded="true"] .chevron { transform: rotate(90deg); }
.inline .list { border: 0; border-top: 1px solid var(--border-subtle); padding: var(--space-2) var(--space-4) var(--space-3); }
.inline .link { border-inline-start: 0; margin-inline-start: 0; padding-inline: 0; }
.inline .link[data-level="3"] { padding-inline-start: var(--space-4); }
.inline .link[data-level="4"] { padding-inline-start: var(--space-8); }
.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .link, .toTop, .chevron { transition: none; } }
toc.tsxtoc.tsxtoc.tsxtsx
"use client";
import { useEffect, useId, useState } from "react";
import { playUiSound } from "../sound/play";
import { UiIcon } from "../theme/ui-icon";
import styles from "./toc.module.css";
import { prefersReducedMotion } from "../lib/use-reduced-motion";
export interface TocEntry {
id: string;
text: string;
/** Heading depth; 2 and 3 are indented apart. */
level: number;
}
/**
* The heading the reader is inside: the last one whose top has passed just under the sticky header. Reading position
* is not the same as "what is on screen", which is why this is not an IntersectionObserver — a long section would
* leave nothing marked once its heading scrolled away.
*/
function useActiveHeading(entries: TocEntry[], offset: number): string | undefined {
const [active, setActive] = useState<string | undefined>(undefined);
useEffect(() => {
if (entries.length === 0 || typeof window === "undefined") return;
let frame = 0;
const update = () => {
frame = 0;
let current: string | undefined;
for (const entry of entries) {
const el = document.getElementById(entry.id);
if (el && el.getBoundingClientRect().top <= offset) current = entry.id;
}
// At the very bottom the last heading may never reach the top, so mark it once the page cannot scroll further.
if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2) current = entries[entries.length - 1].id;
setActive(current);
};
const onScroll = () => { if (!frame) frame = requestAnimationFrame(update); };
frame = requestAnimationFrame(update);
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
if (frame) cancelAnimationFrame(frame);
};
}, [entries, offset]);
return active;
}
export interface BackToTopProps {
label?: string;
/** The id focus moves to, so a keyboard reader lands at the start of the content and not on a button that scrolled away. */
focusId?: string;
className?: string;
}
/** Goes back to the top and moves focus with it. */
export function BackToTop({ label = "맨 위로", focusId = "content", className }: BackToTopProps) {
return (
<button
type="button"
className={[styles.toTop, className].filter(Boolean).join(" ")}
onClick={() => {
playUiSound("tick");
const reduced = prefersReducedMotion();
window.scrollTo({ top: 0, behavior: reduced ? "auto" : "smooth" });
document.getElementById(focusId)?.focus({ preventScroll: true });
}}
>
<UiIcon iconRole="arrow-up" size={16} strokeWidth={1.75} aria-hidden="true" />
{label}
</button>
);
}
function TocList({ entries, active, onPick }: { entries: TocEntry[]; active?: string; onPick?: () => void }) {
return (
<ol className={styles.list}>
{entries.map(entry => (
<li key={entry.id}>
<a
href={`#${entry.id}`}
className={styles.link}
data-level={entry.level}
aria-current={active === entry.id ? "true" : undefined}
onClick={() => { playUiSound("tick"); onPick?.(); }}
>
{entry.text}
</a>
</li>
))}
</ol>
);
}
export interface TocProps {
entries: TocEntry[];
title?: string;
/** How far under the top of the window a heading counts as reached. Match your sticky header's height. */
offset?: number;
/** Adds "맨 위로" under the list. Only for the `aside` shape. */
backToTop?: boolean;
className?: string;
}
/** The sticky "이 페이지에서" column beside a document, with the reading position marked. */
export function TocAside({ entries, title = "이 페이지에서", offset = 140, backToTop = true, className }: TocProps) {
const active = useActiveHeading(entries, offset);
const titleId = useId();
if (entries.length === 0) return null;
return (
<aside aria-labelledby={titleId} data-print="hide" className={[styles.aside, className].filter(Boolean).join(" ")}>
<nav aria-labelledby={titleId}>
<p id={titleId} className={styles.title}>{title}</p>
<TocList entries={entries} active={active} />
</nav>
{backToTop ? <BackToTop /> : null}
</aside>
);
}
/** The same list folded into a disclosure, for when there is no room for a column. */
export function TocInline({ entries, title = "이 페이지에서", className }: TocProps) {
const [open, setOpen] = useState(false);
const listId = useId();
if (entries.length === 0) return null;
return (
<nav aria-label={title} data-print="hide" className={[styles.inline, className].filter(Boolean).join(" ")}>
<button
type="button"
className={styles.toggle}
aria-expanded={open}
aria-controls={listId}
onClick={() => { playUiSound("tick"); setOpen(v => !v); }}
>
<UiIcon iconRole="chevron-right" size={14} strokeWidth={1.75} aria-hidden="true" className={styles.chevron} />
<span className={styles.toggleLabel}>{title}</span>
<span className={styles.count}>{entries.length}<span className={styles.srOnly}>개 단락</span></span>
</button>
<div id={listId} hidden={!open}>
<TocList entries={entries} onPick={() => setOpen(false)} />
</div>
</nav>
);
}
export interface TocVariantProps extends TocProps {
/** `aside` is the sticky column beside the text; `inline` folds the same list into a disclosure. */
variant?: "aside" | "inline";
}
/**
* The table of contents in either shape. A page usually renders both — the column for wide screens and the
* disclosure for narrow ones — and hides whichever does not fit, so the reader always has one.
*/
export function Toc({ variant = "aside", ...props }: TocVariantProps) {
return variant === "inline" ? <TocInline {...props} /> : <TocAside {...props} />;
}
Toc.displayName = "Toc";
TocAside.displayName = "TocAside";
TocInline.displayName = "TocInline";
BackToTop.displayName = "BackToTop";
export default Toc;