Data Display

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

ViewingUsage

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

Viewing Results

Custom Delays

ViewingTimeline

Hierarchical

Viewingpnpm

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.

ViewingTokens

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.

ViewingBreaking changes

Press Mode

ViewingScopes

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.

ViewingSection 13

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 with gap: 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 by var(--floating-toc-bar-level-step) per level, floored at 6px so a deep bar is still a bar; --separator ink, --muted while the rail is hovered, --accent when active.
  • .floating-toc__content — The panel. A floating surface, so it keeps box-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 at min(60vh, 420px) with overflow-y: auto, so a long contents scrolls inside the panel instead of running off the viewport.
  • .floating-toc__item — The <button>. --muted ink, --default on hover, --accent-soft with --foreground ink and medium weight when active. Indents by var(--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-width16px. Length of a level-0 bar.
  • --floating-toc-bar-active-width24px. Length of the active bar.
  • --floating-toc-bar-height2px. Thickness of every bar.
  • --floating-toc-bar-level-step3px. Subtracted per level, so deeper reads shorter.
  • --floating-toc-bar-gap12px. Space between bars.

On .floating-toc__content:

  • --floating-toc-item-indent0.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 the level prop. Defaults to 0, so a bar or item outside any hierarchy needs nothing.

Data Attributes

  • [data-placement="left" | "right"] — On .floating-toc__trigger, from the root's placement. 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__trigger while the panel is open.
  • [data-active="true"] — On .floating-toc__bar and .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

PropTypeDefaultDescription
childrenReactNodeThe 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.
openbooleanControlled open state.
defaultOpenbooleanfalseUncontrolled initial open state.
onOpenChange(open: boolean) => voidFired when the open state changes, from either the pointer or the keyboard.
openDelaynumber200Milliseconds before the panel opens on hover. Ignored under triggerMode="press", and by focus, which opens immediately.
closeDelaynumber300Milliseconds 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

PropTypeDefaultDescription
childrenReactNodeThe bars — one FloatingToc.Bar per section. Required.
align"start" | "end"matches placementWhich 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-labelstring"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.
classNamestringClass name for the rail.
…button propsComponentPropsWithRef<"button">Every native button attribute, plus ref. Renders a real <button>, so type="button" and keyboard activation are already handled.

FloatingToc.Bar

PropTypeDefaultDescription
activebooleanfalseMarks the section in view. Widens the bar to --floating-toc-bar-active-width and inks it in --accent.
levelnumber0Nesting depth, 0-based. Each step subtracts --floating-toc-bar-level-step from the width, floored at 6px.
…span propsComponentPropsWithRef<"span">Every native span attribute, plus ref. The bar is decorative and always aria-hidden.

FloatingToc.Content

PropTypeDefaultDescription
childrenReactNodeThe items — one FloatingToc.Item per section. Wrapped in a <ul>. Required.
offsetnumber8Distance in px between the rail and the panel.
placementPlacementopposite the root'sOverrides 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-labelstring"Sections"Accessible name for the <ul> of sections.
…Popover propsPopoverPropsEvery React Aria Popover prop except isOpen and triggerRef, which the root owns.

FloatingToc.Item

PropTypeDefaultDescription
childrenReactNodeThe section's label. Required.
activebooleanfalseMarks 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.
levelnumber0Nesting depth, 0-based. Each step adds --floating-toc-item-indent to the inline-start padding.
…button propsComponentPropsWithRef<"button">Every native button attribute, plus ref. Renders a real <button> inside its own <li>.

On this page