component — display
List
A list of same-shaped things: Item rows (media, title, secondary line, meta, action) arranged divided or plain, comfortable or compact, flush with the page or inset to a Card's edges — plus ListLink for rows that navigate, ListSection for grouped lists and ListEmpty for the list with nothing in it. Selection, keyboard row navigation and tabular data are GridList, ListBox and DataTable.
- display
- list
- rows
- stacked-list
- empty-state
- navigation
use a list when
- Several things that share the same fields, read down a column: members, files, notifications, projects.
- Rows that each lead to their own page — a
ListLinkin the title makes the whole row the target. - Rows with one or two actions of their own, in
ItemActions— a Button or a Menu trigger. - One list as the content of a Card on a dashboard, with
insetso the rows run to the card's edges. - A long list split by date or status, one
ListSectionper group.
don't
- Don't use a List when the user selects rows, moves through them with arrow keys, or drags them. That is a
GridList. - Don't use a List for picking one option from several. That is a
ListBox, or aSelect/ComboBoxwhen it lives in a field. - Don't use a List for one record's fields. A label and its value are a
DescriptionList. - Don't use a List when the reader compares values across rows, sorts or filters them. Columns are a
Table—DataTableorServerTable. - Don't give each row its own Card. A card per row reads slower and stops being readable at about six; the hairline is the separation.
- Don't render an empty
<li>saying there is nothing here. RenderListEmptyin place of the list.
08 examples
Divided
The default, and the design's index list: a strong rule on top, a hairline between rows and nothing around them — the list sits on the page, and each row is an `Item`: media, title and secondary line, meta, an action.
- Aurelia Vance
aurelia@quebi.de
Owner - Jonas Keller
jonas@quebi.de
Editor - Mira Roth
mira@quebi.de
Viewer
Plain
`variant="plain"` drops the hairlines. For short lists whose rows already have a strong left edge — an icon tile down every row does the separating.
- Q3 report.pdf2.4 MB
- hero-banner.png812 KB
- Brand assets14 files
Compact
`density="compact"` halves each row's vertical padding, for a long list that is read as a whole or a list in a sidebar. Keep the rows to one line when you use it.
- Productioneu-central-1
- Stagingeu-central-1
- Previeweu-west-1
- Developmenteu-west-1
- Load testus-east-1
- Sandboxus-east-1
Inset in a card
When the list is the content of a Card, `inset` runs the rows — and the hairlines between them — to the card's edges, while the text stays in line with the card's header. One card round the list, never one per row.
- Aurelia VanceOwner
- Jonas KellerEditor
- Mira RothViewer
Rows that navigate
A `ListLink` in the title stretches over the whole row: one target, one tab stop, a raised ground and a 12px shift on hover, and a focus ring inside the row. The action slot sits above it, so the row's own Button still takes its own press. This is navigation — rows the user selects or arrows through are a GridList.
Static rows beside rows that navigate
The difference is in the title, not the list. A row with no `ListLink` is static — nothing but its action takes focus — so the reader can tell a row that goes somewhere from one that only describes something.
#412 · opened by Jonas
- Draft: billing export
Not pushed yet
Grouped
`ListSection` puts a heading over each list and names the section by it. Split a long list by the thing the reader scans for — date, status, owner — and keep every group in the same shape.
Today
- Jonas opened #412
Add a List page
- Deploy finished
Production, 3 services
Earlier this week
- Mira merged #409
Panel component
- Q3 report shared
With the finance team
Empty
`ListEmpty` renders in place of the list, not inside it: what would be here, and the action that puts the first one there. An empty `<li>` would be announced as a list of one item.
no pending invitations
Invite a teammate and they will show up here until they accept.
what to use instead
by jobBy what the list was doing.
- Rows the user acts on
- Data with fields
- DescriptionList — A single record.
- DataTable — All the rows are in the browser.
- ServerTable — The server owns the query.
- Items with a magnitude or a rank
- Short items
source
list.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import { useId } from "react"
import { Link, type LinkProps } from "@/components/link"
import { cn } from "@/lib/utils"
/**
* List — quebi design system
*
* The answer to "how do I show a list of things": a `<ul>` of `Item` rows, and
* the handful of decisions a list makes about its rows as a whole — whether a
* hairline runs between them, how much room each one gets, whether they run to
* the edges of the Card they sit in. The rows themselves are `Item` (leading
* media, title, secondary line, meta, action); this file only arranges them.
*
* - `variant="divided"` is the index list: a strong rule on top, a hairline
* between rows. `plain` separates rows by space alone.
* - `density="compact"` halves the vertical padding, for a sidebar or a
* long list read as a whole. It sets the padding of the `Item`s directly
* inside it, so it wins over a `py-*` written on one row.
* - `inset` is for a list that is a Card's content: it pulls the list out
* through the Card's `p-5` and pads each row back in by the same amount, so
* the hairlines run the full width of the card while the text stays in line
* with the card's header.
*
* A row that goes somewhere puts a `ListLink` in its title. The link stretches
* over the whole row with a pseudo-element, so the row is one target and one
* tab stop, and the `ItemActions` slot is lifted above it so a Button in the
* same row still gets its own press. Hovering a linked row tints it and shifts
* its content right — the index list's one piece of motion. That is navigation, not selection: rows
* the user moves through with arrow keys, selects or drags are a `GridList`,
* and rows the user picks one of are a `ListBox`.
*
* `ListSection` gives a run of lists a heading each; `ListEmpty` is what
* renders *instead of* the list when there is nothing in it — never an `<li>`
* inside it, which a screen reader would announce as a list of one item.
*/
export interface ListProps extends React.ComponentProps<"ul"> {
/** `divided` is a rule on top and a hairline between rows; `plain` separates them by space alone. */
variant?: "divided" | "plain"
/** Vertical room per row. `compact` for long lists and sidebars. */
density?: "comfortable" | "compact"
/** Run the rows to the edges of the enclosing Card (its `p-5`). */
inset?: boolean
}
export function List({
className,
variant = "divided",
density = "comfortable",
inset = false,
ref,
...props
}: ListProps) {
return (
<ul
ref={ref}
data-slot="list"
data-variant={variant}
data-density={density}
// `list-style: none` drops list semantics in Safari/VoiceOver; the role
// puts them back, so a reader still hears "list, 3 items".
// biome-ignore lint/a11y/noRedundantRoles: restores list semantics that WebKit drops for an unstyled <ul>
role="list"
className={cn(
"flex flex-col",
// Every row is the containing block for a ListLink's stretched target,
// and the action slot sits above that target so its Button is pressable.
"*:data-[slot=item]:relative **:data-[slot=item-actions]:relative **:data-[slot=item-actions]:z-10",
variant === "divided" &&
"divide-y divide-quebi-hairline border-t border-quebi-rule forced-colors:divide-[ButtonBorder] forced-colors:border-[ButtonBorder]",
density === "compact" ? "*:data-[slot=item]:py-1.5" : "*:data-[slot=item]:py-3.5",
// A linked row is raised and shifts right 12px on hover. The tint sits on
// the row, not on the link's stretched target: that target paints above
// the row's text, and the raised ground is opaque.
"*:data-[slot=item]:transition-[padding,background-color] *:data-[slot=item]:duration-300",
"*:data-[slot=item]:has-[[data-slot=list-link]:hover]:bg-quebi-raised",
inset
? "-mx-5 *:data-[slot=item]:px-5 *:data-[slot=item]:has-[[data-slot=list-link]:hover]:pl-8"
: "*:data-[slot=item]:has-[[data-slot=list-link]:hover]:pl-3",
className,
)}
{...props}
/>
)
}
export interface ListLinkProps extends Omit<LinkProps, "className"> {
className?: string
}
/**
* The title of a row that navigates. Put it inside `ItemTitle`; it covers the
* whole row, raises it on hover and draws the focus ring inside the row rather
* than round the words.
*/
export function ListLink({ className, ...props }: ListLinkProps) {
return (
<Link
data-slot="list-link"
className={cn(
"text-quebi-fg no-underline hover:text-quebi-fg hover:no-underline",
"focus-visible:ring-0 focus-visible:ring-offset-0",
"after:absolute after:inset-0",
"data-focus-visible:after:ring-2 data-focus-visible:after:ring-inset data-focus-visible:after:ring-quebi-focus",
"focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-quebi-focus",
className,
)}
{...props}
/>
)
}
export interface ListSectionProps extends Omit<React.ComponentProps<"section">, "title"> {
/** The section's heading. It also names the section for assistive tech. */
title: React.ReactNode
/** Heading level, so the section fits the outline of the page it is on. */
level?: 2 | 3 | 4
}
/** A heading over one of several lists on a page — "Today", "Earlier", "Archived". */
export function ListSection({
className,
title,
level = 3,
children,
ref,
...props
}: ListSectionProps) {
const id = useId()
const Heading = `h${level}` as const
return (
<section
ref={ref}
data-slot="list-section"
aria-labelledby={id}
className={cn("flex flex-col not-first:mt-6", className)}
{...props}
>
<Heading id={id} className="pb-2">
<span className="quebi-eyebrow">{title}</span>
</Heading>
{children}
</section>
)
}
export interface ListEmptyProps extends Omit<React.ComponentProps<"div">, "title"> {
/** An `IconTile`, usually. */
icon?: React.ReactNode
title: React.ReactNode
description?: React.ReactNode
}
/**
* What renders instead of a `List` with no rows: what would be here, and — in
* `children` — the action that puts the first one there.
*/
export function ListEmpty({
className,
icon,
title,
description,
children,
ref,
...props
}: ListEmptyProps) {
return (
<div
ref={ref}
data-slot="list-empty"
className={cn("flex flex-col items-center gap-1 px-6 py-10 text-center", className)}
{...props}
>
{icon && <div className="mb-2">{icon}</div>}
<p className="font-display text-quebi-title text-quebi-fg">{title}</p>
{description && (
<p className="max-w-xs text-sm/5 text-pretty text-quebi-fg-muted">{description}</p>
)}
{children && <div className="mt-3 flex items-center gap-2">{children}</div>}
</div>
)
}