component
ListItem
One row you can pick in a list or a nav. A label, an optional line under it, and optional icons and a status dot.
Hover, press or tab to one to see the other 6 states.
import { ListItem } from 'rost/components/list-item/ListItem'
<ListItem href="/invoices" selected aria-current="page" leading={<InvoiceIcon />}>Invoices</ListItem>
<ListItem href="/clients" description="12 active" badge action={<ChevronRight />}>Clients</ListItem>src/components/list-item/ListItem.tsx
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react'
import './list-item.css'
interface Common {
/** The picked one: a solid green plate. In a nav, also set `aria-current="page"`. */
readonly selected?: boolean
/** One short line under the label. */
readonly description?: ReactNode
/** A 20px icon before the text. Draw it with `currentColor`. */
readonly leading?: ReactNode
/** Shows the status dot on the right. */
readonly badge?: boolean
/** A 20px icon at the end, usually a chevron. Draw it with `currentColor`. */
readonly action?: ReactNode
/** The label. */
readonly children?: ReactNode
readonly className?: string
/** `span` draws it without the link or button, for a preview inside something that's already one. */
readonly as?: 'span'
}
export type ListItemProps =
| (Common & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> & { readonly href?: undefined; readonly type?: 'button' | 'submit' | 'reset' })
| (Common & AnchorHTMLAttributes<HTMLAnchorElement> & { readonly href: string; readonly disabled?: boolean })
const b = 'rost-list-item'
/**
* One row you can pick in a list or a nav. A link when it has an `href`,
* otherwise a `<button>`. Hover, pressed and focus are the browser's.
* Built from contracts/list-item.contract.yaml.
*/
export function ListItem(props: ListItemProps) {
const { selected = false, description, leading, badge = false, action, children, className, as, ...rest } = props
// No label and nothing at the end: just the icon, centred, as in a collapsed nav.
const iconOnly = !children && !description && !badge && !action
const cls = [b, selected && `${b}--selected`, iconOnly && `${b}--icon-only`, className].filter(Boolean).join(' ')
const inner = (
<>
<span className={`${b}__background`} />
<span className={`${b}__start`}>
{leading && <span className={`${b}__leading`} aria-hidden="true">{leading}</span>}
{(children || description) && (
<span className={`${b}__text`}>
<span className={`${b}__label`}>{children}</span>
{description && <span className={`${b}__description`}>{description}</span>}
</span>
)}
</span>
{(badge || action) && (
<span className={`${b}__end`}>
{badge && <span className={`${b}__badge`}><span className={`${b}__dot`} /></span>}
{action && <span className={`${b}__action`} aria-hidden="true">{action}</span>}
</span>
)}
<span className={`${b}__focus-ring`} />
</>
)
if (as === 'span') return <span className={cls}>{inner}</span>
if ('href' in rest && rest.href !== undefined) {
const { href, disabled, ...a } = rest as AnchorHTMLAttributes<HTMLAnchorElement> & { disabled?: boolean }
return <a className={cls} href={disabled ? undefined : href} aria-disabled={disabled || undefined} {...a}>{inner}</a>
}
const { type = 'button', ...btn } = rest as ButtonHTMLAttributes<HTMLButtonElement>
return <button type={type} className={cls} {...btn}>{inner}</button>
}When to use
- Used for a list of things to pick from, or the items in a side nav: one row each, the picked one in green.
When not to use
- A row of a few options side by side. That's Tabs or Toggle.
- Long text. The label and the line under it stay on one line each.
Behaviour
selected is the picked one. Hover, pressed and focus come from the browser, and disabled fades the whole row to opacity/gentle. On a selected row the status dot turns dark, the way Figma switches the badge to the light theme there.
Accessibility
- Element: a link (
<a href>, witharia-current="page"on the current one in a nav), or a<button>that does something. - Name: the label. The icons are
aria-hidden; the status dot needs words nearby if it means something. - Keyboard: nothing of its own. Tab reaches it, Enter follows the link, and Enter or Space presses the button.
- Focus: the
focus-ringpart, on:focus-visible.
Content
A noun or a short phrase, sentence case. The description is one short line.
Props
| Prop | Type | Values | Default |
|---|---|---|---|
| label | slot | ||
| description | slot | ||
| leading | slot | ||
| badge | boolean | false | |
| action | slot | ||
| selected | boolean | false | |
| iconOnly | boolean | false |
States: Default, Hover, Pressed, Focus, Disabled.
Values
Straight from the contract. Each value sits next to its token, because six tokens here all come out as #3a3a3a and the colour alone can't tell you which one is right.
Never changes
| Part | Property | Value | Token |
|---|---|---|---|
| root | display | flex | layout |
| root | align-items | center | layout |
| root | position | relative | layout |
| root | width | 100% | layout |
| root | gap | 8px | spacing/gap/small |
| root | padding-top | 8px | spacing/gap/small |
| root | padding-bottom | 8px | spacing/gap/small |
| root | padding-left | 12px | spacing/gap/small-medium |
| root | padding-right | 12px | spacing/gap/small-medium |
| root | min-height | 48px | spacing/size/medium-large |
| root | border-radius | 12px | spacing/radius/medium |
| root | background | none | Figma frame size |
| root | border | none | Figma frame size |
| root | font | inherit | Figma frame size |
| root | text-align | left | layout |
| root | text-decoration | none | Figma frame size |
| root | cursor | pointer | Figma frame size |
| root | outline | none | Figma frame size |
| background | position | absolute | layout |
| background | inset | 4px | layout |
| background | border-radius | 8px | spacing/radius/small |
| background | pointer-events | none | Figma frame size |
| start | display | flex | layout |
| start | align-items | center | layout |
| start | flex | 1 1 0 | layout |
| start | min-width | 0 | Figma frame size |
| start | position | relative | layout |
| start | gap | 8px | spacing/gap/small |
| leading | display | flex | layout |
| leading | flex-shrink | 0 | layout |
| leading | width | 20px | spacing/size/xsmall |
| leading | height | 20px | spacing/size/xsmall |
| text | display | flex | layout |
| text | flex-direction | column | layout |
| text | gap | 2px | spacing/gap/3xsmall |
| text | padding-bottom | 2px | spacing/gap/3xsmall |
| text | min-width | 0 | Figma frame size |
| label | white-space | nowrap | layout |
| label | font-family | 'General Sans Variable' | font-family/base |
| label | font-weight | 400 | typography/paragraph/font-weight/regular |
| label | font-size | 14px | typography/paragraph/font-size |
| label | line-height | 20px | typography/paragraph/line-height |
| label | letter-spacing | 0px | typography/paragraph/letter-spacing |
| description | white-space | nowrap | layout |
| description | font-family | 'General Sans Variable' | font-family/base |
| description | font-weight | 400 | typography/label/font-weight/regular |
| description | font-size | 10px | typography/placeholder/font-size |
| description | line-height | 12px | typography/placeholder/line-height |
| description | letter-spacing | 0px | typography/placeholder/letter-spacing |
| end | display | flex | layout |
| end | align-items | center | layout |
| end | position | relative | layout |
| end | gap | 8px | spacing/gap/small |
| badge | display | flex | layout |
| badge | align-items | center | layout |
| badge | justify-content | center | layout |
| badge | width | 24px | spacing/size/small |
| badge | height | 24px | spacing/size/small |
| dot | width | 8px | on purpose: The small Badge's own 8px dot. |
| dot | height | 8px | on purpose: The small Badge's own 8px dot. |
| dot | border-radius | 10000px | spacing/radius/full |
| dot | background | #ededed | non-interactive/background/contrast |
| action | display | flex | layout |
| action | flex-shrink | 0 | layout |
| action | width | 20px | spacing/size/xsmall |
| action | height | 20px | spacing/size/xsmall |
| focus-ring | display | none | layout |
| focus-ring | position | absolute | layout |
| focus-ring | inset | 0 | layout |
| focus-ring | border-style | solid | no token yet |
| focus-ring | border-width | 2px | spacing/outline/medium |
| focus-ring | border-radius | 12px | spacing/radius/medium |
| focus-ring | pointer-events | none | Figma frame size |
Changes by variant
action.color10
| state=Default, selected=True | #060805 | interactive/active/default/icon |
| state=Hover, selected=True | #060805 | interactive/active/hover/icon |
| state=Pressed, selected=True | #060805 | interactive/active/pressed/icon |
| state=Focus, selected=True | #060805 | interactive/active/focus/icon |
| state=Disabled, selected=True | #060805 | interactive/active/disabled/icon |
| state=Default, selected=False | #00d669 | interactive/inactive/default/icon |
| state=Hover, selected=False | #00b450 | interactive/inactive/hover/icon |
| state=Pressed, selected=False | #00d669 | interactive/inactive/pressed/icon |
| state=Focus, selected=False | #00d669 | interactive/inactive/focus/icon |
| state=Disabled, selected=False | #00d669 | interactive/inactive/disabled/icon |
background.background7
| state=Default, selected=True | #00d669 | interactive/active/default/background |
| state=Hover, selected=True | #00b450 | interactive/active/hover/background |
| state=Pressed, selected=True | #00913a | interactive/active/pressed/background |
| state=Focus, selected=True | #00d669 | interactive/active/focus/background |
| state=Disabled, selected=True | #00d669 | interactive/active/disabled/background |
| state=Hover, selected=False | #006f27 | interactive/inactive/hover/background |
| state=Pressed, selected=False | #00913a | interactive/inactive/pressed/background |
background.opacity7
| state=Default, selected=True | 1 | opacity/solid |
| state=Hover, selected=True | 1 | opacity/solid |
| state=Pressed, selected=True | 1 | opacity/solid |
| state=Focus, selected=True | 1 | opacity/solid |
| state=Disabled, selected=True | 1 | opacity/solid |
| state=Hover, selected=False | 0.24 | opacity/gentle |
| state=Pressed, selected=False | 0.24 | opacity/gentle |
description.color10
| state=Default, selected=True | #282828 | interactive/active/default/sub-label |
| state=Hover, selected=True | #3a3a3a | interactive/active/hover/sub-label |
| state=Pressed, selected=True | #3a3a3a | interactive/active/pressed/sub-label |
| state=Focus, selected=True | #3a3a3a | interactive/active/focus/sub-label |
| state=Disabled, selected=True | #3a3a3a | interactive/active/disabled/sub-label |
| state=Default, selected=False | #999999 | interactive/inactive/default/sub-label |
| state=Hover, selected=False | #d3d9d5 | interactive/inactive/hover/sub-label |
| state=Pressed, selected=False | #c2c7c3 | interactive/inactive/pressed/sub-label |
| state=Focus, selected=False | #999999 | interactive/inactive/focus/sub-label |
| state=Disabled, selected=False | #999999 | interactive/inactive/disabled/sub-label |
dot.background5
| state=Default, selected=True | #060805 | non-interactive/background/primary |
| state=Hover, selected=True | #060805 | non-interactive/background/primary |
| state=Pressed, selected=True | #060805 | non-interactive/background/primary |
| state=Focus, selected=True | #060805 | non-interactive/background/primary |
| state=Disabled, selected=True | #060805 | non-interactive/background/primary |
focus-ring.border-color2
| state=Focus, selected=True | #b6ffda | interactive/active/focus/focus-ring |
| state=Focus, selected=False | #999999 | interactive/inactive/focus/focus-ring |
focus-ring.display2
| state=Focus, selected=True | block | layout |
| state=Focus, selected=False | block | layout |
label.color10
| state=Default, selected=True | #060805 | interactive/active/default/label |
| state=Hover, selected=True | #060805 | interactive/active/hover/label |
| state=Pressed, selected=True | #060805 | interactive/active/pressed/label |
| state=Focus, selected=True | #060805 | interactive/active/focus/label |
| state=Disabled, selected=True | #060805 | interactive/active/disabled/label |
| state=Default, selected=False | #d3d9d5 | interactive/inactive/default/label |
| state=Hover, selected=False | #ededed | interactive/inactive/hover/label |
| state=Pressed, selected=False | #d3d9d5 | interactive/inactive/pressed/label |
| state=Focus, selected=False | #d3d9d5 | interactive/inactive/focus/label |
| state=Disabled, selected=False | #d3d9d5 | interactive/inactive/disabled/label |
leading.color10
| state=Default, selected=True | #060805 | interactive/active/default/icon |
| state=Hover, selected=True | #060805 | interactive/active/hover/icon |
| state=Pressed, selected=True | #060805 | interactive/active/pressed/icon |
| state=Focus, selected=True | #060805 | interactive/active/focus/icon |
| state=Disabled, selected=True | #060805 | interactive/active/disabled/icon |
| state=Default, selected=False | #00d669 | interactive/inactive/default/icon |
| state=Hover, selected=False | #00b450 | interactive/inactive/hover/icon |
| state=Pressed, selected=False | #00d669 | interactive/inactive/pressed/icon |
| state=Focus, selected=False | #00d669 | interactive/inactive/focus/icon |
| state=Disabled, selected=False | #00d669 | interactive/inactive/disabled/icon |
root.justify-content1
| iconOnly=True | center | layout |
root.opacity2
| state=Disabled, selected=True | 0.24 | opacity/gentle |
| state=Disabled, selected=False | 0.24 | opacity/gentle |
start.flex1
| iconOnly=True | none | layout |