Floating TOC
A rail of bars pinned to the edge of a page that opens a panel of its sections — hover or press, either side, at any depth.
Usage
FloatingToc is the table of contents that lives at the page margin: a stack of short rules, one per
section, that expands into a readable list when you reach for it. The rail costs almost no space, so
it can stay on screen for the whole of a long document.
The API is composable rather than a single component with props, which is the same exception the Legend, the Timeline and the Empty Panel take. A table of contents is an arbitrary-length list of sections at arbitrary depths, and you already hold that list — props would only make you restate it in a shape the component invented.
FloatingToc.Trigger is position: fixed, so it floats against the nearest containing block. Every
demo on this page wraps it in an element carrying [contain:layout], which makes that element the
containing block and anchors the rail to the section instead of the viewport. Drop the containment
and the rail pins to the window, which is what you want on a real page.
The component reports the press and stops there — it does not own selection or scrolling. active is
yours to hold, and FloatingToc.Item takes the native onClick, so a click is wired the same way you
would wire any button. Every demo below does exactly that: the item sets active, which moves both
the item highlight and the bar on the rail. The two In Page demos go one step further and scroll their
article to the matching heading.
The bars themselves are not pressable, by design. FloatingToc.Trigger is a single <button> that
opens the panel, and the bars are aria-hidden decoration inside it — making each bar its own control
would nest a button inside a button. The rail opens; the panel navigates.
Anatomy
Import the FloatingToc component and access all parts using dot notation.
import {FloatingToc} from "@blakeui/pro-react";
<FloatingToc>
<FloatingToc.Trigger>
<FloatingToc.Bar />
</FloatingToc.Trigger>
<FloatingToc.Content>
<FloatingToc.Item />
</FloatingToc.Content>
</FloatingToc>;The semantics are real, and they are split deliberately. FloatingToc.Trigger is a <button> with
aria-expanded and an accessible name, and the bars inside it are aria-hidden — a screen reader
hears one control, not eight anonymous rules. FloatingToc.Content renders a <ul>, each
FloatingToc.Item is a <button> in its own <li>, and the active one carries
aria-current="location". Escape closes the panel from anywhere and returns focus to the rail.
Controlled
Custom Delays
Hierarchical
In Page Context
Wired both ways: an onScroll handler moves the active section as the prose passes under the top
edge, and clicking an item scrolls the article to that heading. The smooth scroll drops to an instant
jump under either reduced-motion signal.
Abstract
Every section here is a real heading with real prose beneath it, so the rail has something to track as the article moves.
Background
Every section here is a real heading with real prose beneath it, so the rail has something to track as the article moves.
Approach
Every section here is a real heading with real prose beneath it, so the rail has something to track as the article moves.
Evaluation
Every section here is a real heading with real prose beneath it, so the rail has something to track as the article moves.
Conclusion
Every section here is a real heading with real prose beneath it, so the rail has something to track as the article moves.
Left Aligned Bars
Alignment, not placement: the rail stays on the right and only the edge the bars line up on moves, so their ragged ends face the margin instead of the content.
Left Placement
Placement moves the whole rail to the other side, takes the bar alignment with it, and opens the panel rightward so it still expands into the content rather than off the edge.
Press Mode
Press Mode In Page
Press and scroll tracking together — the combination a touch surface needs, where there is no hover to wait on and scrolling the article never trips the panel open. Clicking an item jumps the article, exactly as above.
Hardware
Scroll the article and the rail follows. Press the bars to open the panel, then pick a section to jump to it.
Firmware
Scroll the article and the rail follows. Press the bars to open the panel, then pick a section to jump to it.
Pairing
Scroll the article and the rail follows. Press the bars to open the panel, then pick a section to jump to it.
Troubleshooting
Scroll the article and the rail follows. Press the bars to open the panel, then pick a section to jump to it.
Warranty
Scroll the article and the rail follows. Press the bars to open the panel, then pick a section to jump to it.
Long Lists
The panel caps its height at min(60vh, 420px) and scrolls past it, and the rail takes the same
treatment through a utility. The list is not windowed: all forty items below are in the DOM, and
they stay there. If you need virtualization for a table of contents, this component does not do it.
Note the taller box. The panel centres on the rail, so a container shorter than the panel lets it spill past the top and bottom edges — give the rail's containing block room for the panel at its cap and the relationship stays tidy.
CSS Classes
Base Classes
.floating-toc__trigger— The rail, and the hover/press target.position: fixed, vertically centred on its containing block, a flex column withgap: var(--floating-toc-bar-gap). Declares the five bar custom properties.z-index: 40— under the Dock's 50..floating-toc__bar— One rule per section. Width falls byvar(--floating-toc-bar-level-step)per level, floored at 6px so a deep bar is still a bar;--separatorink,--mutedwhile the rail is hovered,--accentwhen active..floating-toc__content— The panel. A floating surface, so it keepsbox-shadow: var(--overlay-shadow)on--overlay— bordered-flat is for the card family, and this layer genuinely floats. Declares--floating-toc-item-indent..floating-toc__list— The<ul>. A flex column capped atmin(60vh, 420px)withoverflow-y: auto, so a long contents scrolls inside the panel instead of running off the viewport..floating-toc__item— The<button>.--mutedink,--defaulton hover,--accent-softwith--foregroundink and medium weight when active. Indents byvar(--floating-toc-level) * var(--floating-toc-item-indent).
CSS Variables
Declared in two scopes, not one. The panel is portaled to <body> and custom properties follow
the DOM tree rather than the React tree, so a variable set on the rail could never reach the panel.
Each is declared where it is actually read.
On .floating-toc__trigger:
--floating-toc-bar-width—16px. Length of a level-0 bar.--floating-toc-bar-active-width—24px. Length of the active bar.--floating-toc-bar-height—2px. Thickness of every bar.--floating-toc-bar-level-step—3px. Subtracted per level, so deeper reads shorter.--floating-toc-bar-gap—12px. Space between bars.
On .floating-toc__content:
--floating-toc-item-indent—0.75rem. Added to the item's inline-start padding per level.
On each bar and item:
--floating-toc-level— The part's own depth, set inline from thelevelprop. Defaults to0, so a bar or item outside any hierarchy needs nothing.
Data Attributes
[data-placement="left" | "right"]— On.floating-toc__trigger, from the root'splacement. Picks which inline edge the rail pins to. Also on.floating-toc__content, where it is RAC's resolved side — it flips when the panel would overflow, and the entrance slide follows the flip.[data-align="start" | "end"]— On.floating-toc__trigger. Which edge the bars line up on, independent of placement.[data-open]— On.floating-toc__triggerwhile the panel is open.[data-active="true"]— On.floating-toc__barand.floating-toc__item, for the section in view.[data-entering="true"]/[data-exiting="true"]— On.floating-toc__content, from React Aria, for the duration of the enter and exit animations.[data-slot]— On every part (floating-toc-trigger,floating-toc-bar,floating-toc-content,floating-toc-item), for targeting a part from outside without depending on a BEM class.
Motion
The panel enters over 150ms and leaves over 100ms on --ease-out, fading and settling from
scale(0.97) with a small slide out of the side it opens toward. Bars cross-fade their colour and
ease their width over 200ms; items cross-fade colour over 120ms, and the press-in snaps while only
the release eases back.
These are CSS keyframes rather than JavaScript, and that is forced rather than preferred: React
Aria's useExitAnimation reads element.getAnimations() in a layout effect the instant the exit
begins and unmounts immediately if the list is empty. Nothing JS-driven can register inside that
window, so only a CSS animation holds the panel alive long enough to leave.
Reduced motion is gentler, not zero. The panel keeps its fade and drops the scale and the slide; the
bars keep their colour crossfade and drop the width change. The item transition is colour-only, so
there is no movement to remove and it survives untouched. Both signals are honoured — an explicit
[data-reduce-motion="true"] on any ancestor, and @media (prefers-reduced-motion: reduce) — with
the media half scoped so the two never double-apply.
API Reference
FloatingToc
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The rail and the panel — FloatingToc.Trigger and FloatingToc.Content. Required. |
placement | "left" | "right" | "right" | Which inline edge the rail pins to. Also sets the default bar alignment and opens the panel toward the opposite side. |
triggerMode | "hover" | "press" | "hover" | "press" drops the pointer handlers entirely and the delays stop applying — nothing opens until the rail is clicked. |
open | boolean | — | Controlled open state. |
defaultOpen | boolean | false | Uncontrolled initial open state. |
onOpenChange | (open: boolean) => void | — | Fired when the open state changes, from either the pointer or the keyboard. |
openDelay | number | 200 | Milliseconds before the panel opens on hover. Ignored under triggerMode="press", and by focus, which opens immediately. |
closeDelay | number | 300 | Milliseconds before the panel closes once the pointer leaves. This is the window the pointer has to cross the gap from the rail to the panel. |
FloatingToc.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The bars — one FloatingToc.Bar per section. Required. |
align | "start" | "end" | matches placement | Which edge the bars line up on, independent of which side the rail sits on. Defaults to the edge nearest the page margin, so the ragged ends face the content. |
aria-label | string | "Table of contents" | The trigger's accessible name. The bars are hidden, so this is the only name assistive technology gets — override it when a page carries more than one rail. |
className | string | — | Class name for the rail. |
…button props | ComponentPropsWithRef<"button"> | — | Every native button attribute, plus ref. Renders a real <button>, so type="button" and keyboard activation are already handled. |
FloatingToc.Bar
| Prop | Type | Default | Description |
|---|---|---|---|
active | boolean | false | Marks the section in view. Widens the bar to --floating-toc-bar-active-width and inks it in --accent. |
level | number | 0 | Nesting depth, 0-based. Each step subtracts --floating-toc-bar-level-step from the width, floored at 6px. |
…span props | ComponentPropsWithRef<"span"> | — | Every native span attribute, plus ref. The bar is decorative and always aria-hidden. |
FloatingToc.Content
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The items — one FloatingToc.Item per section. Wrapped in a <ul>. Required. |
offset | number | 8 | Distance in px between the rail and the panel. |
placement | Placement | opposite the root's | Overrides which side the panel opens toward. By default it is the opposite of the root's placement, so the panel expands into the content. |
aria-label | string | "Sections" | Accessible name for the <ul> of sections. |
…Popover props | PopoverProps | — | Every React Aria Popover prop except isOpen and triggerRef, which the root owns. |
FloatingToc.Item
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The section's label. Required. |
active | boolean | false | Marks the section in view. Adds the --accent-soft background and aria-current="location" — the value defined for the current location within an environment, which is what a contents entry marks. |
level | number | 0 | Nesting depth, 0-based. Each step adds --floating-toc-item-indent to the inline-start padding. |
…button props | ComponentPropsWithRef<"button"> | — | Every native button attribute, plus ref. Renders a real <button> inside its own <li>. |