06 / Wayfinding
Floating Index
A compact outline for long pages. It keeps the active section and reading progress visible without becoming another permanent sidebar.
01
Keep your place.
The index stays close while the page keeps moving.
Scroll this frame to try it.
02
See where you are.
Open it for the full outline, or glance at the progress ring.
03
Jump without getting lost.
Choose a section and the index follows you there.
"use client"
import * as React from "react"
import { BookOpen, CheckCircle2, SlidersHorizontal } from "lucide-react"
import { FloatingIndex } from "mischief-ui/floating-index"
const items = [
{
id: "floating-index-introduction",
label: "Introduction",
icon: <BookOpen aria-hidden="true" />,
},
{
id: "floating-index-behavior",
label: "Behavior",
icon: <SlidersHorizontal aria-hidden="true" />,
},
{
id: "floating-index-finish",
label: "Finish",
icon: <CheckCircle2 aria-hidden="true" />,
},
]
const sections = [
{
id: "floating-index-introduction",
kicker: "01",
title: "Keep your place.",
copy: "The index stays close while the page keeps moving.",
},
{
id: "floating-index-behavior",
kicker: "02",
title: "See where you are.",
copy: "Open it for the full outline, or glance at the progress ring.",
},
{
id: "floating-index-finish",
kicker: "03",
title: "Jump without getting lost.",
copy: "Choose a section and the index follows you there.",
},
]
export function FloatingIndexDemo() {
const containerRef = React.useRef<HTMLDivElement>(null)
return (
<div
ref={containerRef}
className="bg-background relative h-[28rem] w-full max-w-xl overflow-y-auto rounded-[var(--radius)] border"
>
<FloatingIndex
items={items}
containerRef={containerRef}
className="sticky top-4 left-auto mx-auto translate-x-0"
/>
<div className="-mt-11">
{sections.map((section, index) => (
<section
className="flex min-h-[25rem] scroll-mt-4 flex-col justify-end border-b p-8 last:border-b-0"
id={section.id}
key={section.id}
>
<p className="text-primary text-xs font-bold tracking-[0.08em]">
{section.kicker}
</p>
<h3 className="mt-3 font-[family-name:var(--font-display)] text-4xl font-semibold tracking-[-0.04em]">
{section.title}
</h3>
<p className="text-muted-foreground mt-3 max-w-sm leading-relaxed">
{section.copy}
</p>
{index === 0 && (
<p className="text-muted-foreground mt-8 text-xs">
Scroll this frame to try it.
</p>
)}
</section>
))}
</div>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/floating-indeximport { FloatingIndex } from "mischief-ui/floating-index"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { ChevronDown } from "lucide-react"import { AnimatePresence, motion, useReducedMotion } from "motion/react"import { cn } from "@/lib/utils" export type FloatingIndexPosition = "top" | "bottom" | "top-left" | "top-right" | "bottom-left" | "bottom-right" /** Where it sits. Anything else is a matter of className. */const POSITIONS: Record<FloatingIndexPosition, string> = { top: "top-6 left-1/2 -translate-x-1/2", bottom: "bottom-6 left-1/2 -translate-x-1/2",Usage
const items = [
{ id: "introduction", label: "Introduction" },
{ id: "details", label: "Details" },
{ id: "examples", label: "Examples" },
]
export function PageIndex() {
return <FloatingIndex items={items} />
}Watching something other than the window
By default the index tracks the page. When your sections scroll inside an element -- a panel, a modal, a split view -- pass that element and it observes the right scroller instead of quietly tracking a page that never moves.
const panel = useRef<HTMLDivElement>(null)
<div ref={panel} className="overflow-y-auto">
{sections.map((section) => (
<section key={section.id} id={section.id}>…</section>
))}
</div>
<FloatingIndex items={items} containerRef={panel} />Every item's id must match the id of a real element, because that is what is being observed. An item pointing at nothing is simply never marked active.
Where it sits
It floats at the top of the viewport, centred, which suits a page whose header scrolls away. Anywhere else is the position prop rather than a set of utilities cancelling the default one at a time.
<FloatingIndex
items={sections}
position="bottom-right"
label="On this page"
/>Bottom right is where a back-to-top control usually lives, so check they are not stacked on each other before choosing it. className still wins for anything the prop does not cover, such as the width.
The ring
The ring around the index fills with how far through the scroller the reader is, which gives the sense of remaining length that a list of section names alone does not. It is decoration -- the active item is what carries the position, and it is marked as current for a screen reader.
Motion is an optional peer here, so the component is imported from its own entry. With reduced motion the ring stops animating between values and simply reflects the current one.
API
itemsFloatingIndexItem[]Section ids, labels, and optional icons.labelstringThe toggle label. Defaults to Index.showActiveLabelbooleanOnce past the top, the toggle says which section the reader is in rather than repeating the label. Defaults to true.position"top" | "bottom" | "top-left" | "top-right" | "bottom-left" | "bottom-right"Which corner it floats in. Defaults to "top", centred.activeIdstringThe active section when controlled.defaultActiveIdstringThe initial active section.onActiveChange(id: string) => voidRuns when the visible section changes.containerHTMLElement | nullTracks a controlled scroll container that can change after mount.containerRefRefObject<HTMLElement>Tracks a scroll container instead of the page.classNamestringClasses for placement and appearance.FloatingIndexItem
idstringMust match the id of the element it points at.labelstringThe section name.iconReactNodeShown in place of the marker.Accessibility
The index is a labelled navigation landmark with native buttons, visible focus, aria-expanded on the toggle, and aria-current on the active section. Escape closes the outline. Reduced motion removes panel animation and jumps rather than scrolling smoothly. The navigation landmark keeps the name it was given even when the toggle is showing the current section instead, so it is still found by that name.