List

Hover list

A list with one bar sliding between its rows. It is a whole component rather than a hover style, because the bar has to know about rows it is not on.

Usage

import { HoverList } from '@carabine/ui/hover-list';

nothing chosen

<HoverList
  items={[
    { id: 'prod', label: 'Production', description: '3 services · eu-west', icon: <Server /> },
    { id: 'staging', label: 'Staging', description: '1 service', icon: <Server /> },
    { id: 'preview', label: 'Preview', description: 'Built on every push' },
  ]}
  onSelect={(item) => deploy(item.id)}
  label="Environments"
/>

This list says where the pointer is, and that is all it says. There is no selected row — a second claim on the same surface leaves the list pointing at two things.

One bar

The bar is one element behind the rows, moved and resized to the row under the pointer. Rows are different heights when some carry a description and some do not, so both the position and the height are measured and filed under the row's id — which is why the id has to be stable across reorders.

On the first hover the bar is placed rather than animated. Sliding in from the top of the list on the first row you touch is the bar arriving; every hover after that is the bar travelling, which is the thing worth watching.

if (hovered === null) y.set(next);   // first hover: place it
else animate(y, next, SPRING);      // after that: travel

Examples

On a plate

plate puts the toast's glass shell around the rows, for a list standing on its own rather than filling a panel. It is off by default: this is content more often than it is a container, and two panes of glass inside each other is the worse of the two mistakes.

<HoverList items={items} plate onSelect={deploy} label="Environments" />

Customization

<HoverList
  items={items}
  radius={12}      // row corners, and the bar's
  padding={12}     // inside a row, both axes
  gap={3}          // between two rows
  bar="rgba(255,255,255,0.07)"   // a plain CSS string, not a class
  plate
  stiffness={420}
  damping={38}
  renderItem={(item) => <YourRow item={item} />}   // the row stays ours
/>

Styling reference

The bar is a tint: no hairline, no shadow, and letting the surface show through is the whole point. Its colour is the one plain CSS string in the component's API, because it is the thing a consumer is most likely to want in their own brand.

The type scale and the greys are the toast's, to the value — 13px medium over 12px regular. A list sitting inside a toast-shaped panel that used its own greys would read as a widget somebody dropped in.

API reference

PropType
classNamestringAdded to the component’s own classes, so yours wins
styleCSSPropertiesMerged after the component’s own inline styles, so yours wins

Colours are data and dimensions are props, and neither covers a margin, a font, or a class from your own system. That is what these are for. State is legible from CSS as well — data-state, data-disabled, data-side — so a rule can answer it without knowing a single class name of ours. See Theming.

PropTypeDefault
itemsHoverItem[][]
renderItem(item, index) => ReactNodeReplaces a row’s contents; the row stays ours
onSelect(item, index) => voidAlso what turns the rows into buttons
radiusnumber10Row corners, and the bar’s
paddingnumber10Inside a row, both axes
gapnumber2Between two rows
barstringtheme'sThe bar’s colour — a plain CSS string
platebooleanfalseThe toast’s glass shell around the rows
stiffnessnumber420
dampingnumber38
disabledbooleanfalse
labelstringAccessible name of the list
theme'dark' | 'light''dark'

A row's accessible name is everything in it — label and description. A description that is not part of the name is a description nobody hears.

What it refuses

  • No selected row

    The bar answers one question, and it is where the pointer is. A list that also had to show a current value would need a second surface, and two surfaces claiming the same thing is the rule this component exists to demonstrate.

  • No virtualisation

    Every row is measured, and a measured row that is not mounted has no height. A list long enough to need windowing is a table, and a table is a different component.