Skip to content

Index List

A list of names where pointing at one chooses it, the rest step back and its panel shows beside the list.

$npx shadcn@latest add https://framebrick.co/r/index-list.json

From the Tustel template, see it in context.

  • Positioning, audience research and a voice your team can actually keep to.

    Glass-walled office corridor with a sofa and a floor lamp
  • Layouts, type and motion designed together and tested at every screen size.

    Glass towers seen from the street, looking up
  • Fast, accessible sites on Next.js, with content your team edits without a developer.

    Open-plan office with rows of people working at laptops

Installation

$ npx shadcn@latest add https://framebrick.co/r/index-list.json

Usage

import { IndexList } from "@/components/ui/index-list";

<IndexList
  label="Services"
  nameClassName="aria-expanded:text-orange-600"   // colour the chosen name through its aria-expanded state
  items={services.map((s, i) => ({
    value: s.slug,
    marker: String(i + 1).padStart(2, "0"),
    title: s.name,
    panel: (
      <>
        <p>{s.summary}</p>
        <Image src={s.photo} alt="" width={300} height={200} />
      </>
    ),
  }))}
/>

Props

PropTypeDefaultDescription
items{ value, title, marker?, panel }[]—The names. `panel` is what shows while the name is chosen: beside the list when it is wide enough, under the name when it is not.
labelstring—Accessible name of the list.
valuestring—Chosen name (controlled).
defaultValuestringfirst itemName that starts chosen (uncontrolled).
onValueChange(value: string) => void—Called when another name is chosen.
trigger"hover" | "click""hover"Whether pointing at a name with a mouse chooses it, while the panel is beside the list. A click or Enter always does.
dimmednumber0.65Opacity of the names that are not chosen.
panelWidthnumber360Width of the panel when it is beside the list, in px — never more than 55% of the list.
nameClassNamestring—Classes for every name button. The chosen one has aria-expanded="true", so `aria-expanded:` utilities colour it.
classNamestring—Classes for the list.
  • One name is always chosen. Pointing at a name chooses it and it stays chosen when the pointer leaves; a click or Enter chooses it on any device. When the list is under 576 px wide the panel opens under its name by height, and a click on the open name leaves it open.
  • The layout follows the width of the list, not of the window (a container query at 576 px), so it works in a sidebar or a card; Tustel's breakpoints are the window's and broke there. Each panel is in the page once, inside its own list item. When there is room the list becomes a grid and every panel sits in the same cell beside the names, so the content is not duplicated for the two layouts (a copy would repeat ids and state). The names space out if a panel is taller than the list.
  • Pointing only counts for a mouse, and while the panel is beside the list; touch and keyboard use a click or Enter. Whether it is beside the list is read from the list's computed display when the pointer arrives, never while rendering.
  • Tustel dims the other names to 30%, which is 2.1:1 against white. The library steps them back to 65%: 6.3:1 in the default theme, 8:1 in dark, and 5.1:1 or better in all four Blocks sample themes, light and dark (60% was 4.4 in the "soft" theme). Tustel's orange on the chosen name is left out — colour it with `nameClassName`.
  • The names and the phone accordion use Framer's springSoft (bounce 0.2, 0.4 s); the panel beside the list changes at once, with no cross-fade, as in Tustel. Reduced motion is a zero-duration transition, not different markup.

AI prompt

Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.

Create a React + Tailwind CSS + Motion (motion/react) component called IndexList.
- Wrap everything in a div with the @container class (a container query, so the layout follows the list's own width). Inside
  it, a ul with an aria-label: flex column with gap-6; from the @xl container size (576px) a grid with columns minmax(0,1fr)
  and minmax(0,min(var(--index-panel),55%)) (the panel width, default 360px) and gap-x-12. Each li is flex column and, from
  @xl, display: contents, so its name and its panel become grid items of the ul.
- Each name is a motion.button: type button, aria-expanded, aria-controls its panel, text-2xl font-semibold
  tracking-tight (text-3xl from @xl), left-aligned, rounded-md, ring-[3px] ring-ring/50 on focus-visible; an optional
  marker (tabular-nums) comes before the title. It sits in grid column 1. Exactly one name is chosen (the first by
  default); the chosen name animates to opacity 1 and the rest to 0.65 with a spring (bounce 0.2, duration 0.4s).
  Controlled (value, onValueChange) or uncontrolled (defaultValue).
- A click or Enter chooses a name. With trigger="hover", onPointerEnter chooses it too, only for pointerType "mouse" and
  only when the ul's computed display is grid (the panel is beside the list) — read it inside the handler, never while rendering. The chosen name
  stays chosen when the pointer leaves.
- Each panel is a motion.div with role region, aria-labelledby its name, inert while closed and a data-state attribute.
  Narrow, it has overflow hidden and animates height between 0 and auto with the same spring, so the chosen name opens
  under itself and a click on the open one leaves it open. From @xl it is placed in grid column 2 and row 1 spanning all
  rows (inline gridRow: "1 / span var(--index-rows)", the row count set on the ul), centred vertically, with height:auto
  forced (md:h-auto!) and overflow visible; a closed panel is display: none. No cross-fade: the panel changes at once.
- Use useReducedMotion() only to switch the transitions to zero duration — never to change the markup. Ids come from
  useId and the index.

Be the first to know

Get new templates and components first

One short email when we release a template or a free component. No spam — unsubscribe anytime.

We use your email only for Framebrick updates. Privacy policy