Mischief

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.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/floating-index
import { FloatingIndex } from "mischief-ui/floating-index"
Also installs
  • motion
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/floating-index/floating-index.tsx
"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} />
Pass container instead when you already hold the element rather than a ref.

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"
/>
The corners are top, bottom, and the four of them named.

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.