Display
List
Rows with a lead, title, description, meta and a trailing action, as links, buttons or checkboxes.
- Built on
<ul><input type="checkbox">:has()Stretched link pseudo-element- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.8 kB
Try its options
Loading its options.
Code
import { Icon } from "@/components/equip/icon";
import { List, ListItem } from "@/components/equip/list";
export default function Demo() {
return (
<List density="compact" aria-label="Recent files" style={{ width: 208 }}>
<ListItem lead={<Icon name="file-text" size="s" />} title="Q3 board deck.pdf" meta="2.4 MB" href="#files/q3-board-deck" />
<ListItem lead={<Icon name="table" size="s" />} title="Hiring plan.xlsx" meta="88 KB" href="#files/hiring-plan" />
<ListItem lead={<Icon name="image" size="s" />} title="Launch hero.png" meta="1.1 MB" href="#files/launch-hero" />
</List>
);
}Your versionchange its options in Look, This part
Examples
Links with a trailing action
The whole row is one link named by its title; the size and date are read as its description, and the more button stays a separate stop.
- Q3 board deck.pdfEdited today
- Hiring plan.xlsxEdited yesterday
- Brand guidelines v4.pdfEdited 12 Oct
Code
import { Button } from "@/components/equip/button";
import { Icon } from "@/components/equip/icon";
import { List, ListItem } from "@/components/equip/list";
const files = [
{ name: "Q3 board deck.pdf", icon: "file-text", size: "2.4 MB", when: "Edited today", slug: "q3-board-deck" },
{ name: "Hiring plan.xlsx", icon: "table", size: "88 KB", when: "Edited yesterday", slug: "hiring-plan" },
{ name: "Brand guidelines v4.pdf", icon: "book", size: "14 MB", when: "Edited 12 Oct", slug: "brand-guidelines" },
] as const;
export default function Example() {
return (
<List aria-label="Shared with you" style={{ maxWidth: 420 }}>
{files.map((f) => (
<ListItem
key={f.slug}
lead={<Icon name={f.icon} />}
title={f.name}
description={f.when}
meta={f.size}
href={`#files/${f.slug}`}
trail={
<Button variant="ghost" size="s" aria-label={`More actions for ${f.name}`} lead={<Icon name="more" />} />
}
/>
))}
</List>
);
}Selectable
Each row is a native checkbox labelled by its title, so it submits with a form and clicking anywhere on the row toggles it.
Code
import { Icon } from "@/components/equip/icon";
import { List, ListItem } from "@/components/equip/list";
export default function Example() {
return (
<fieldset style={{ margin: 0, padding: 0, border: 0, minWidth: 0, maxWidth: 380 }}>
<legend style={{ marginBottom: 8, padding: 0, fontSize: 13, fontWeight: 650 }}>Files to export</legend>
<List>
<ListItem
lead={<Icon name="file-text" />}
title="Invoices 2026.csv"
meta="312 KB"
check={{ name: "export", value: "invoices", defaultChecked: true }}
/>
<ListItem
lead={<Icon name="file-text" />}
title="Customers.csv"
meta="1.8 MB"
check={{ name: "export", value: "customers", defaultChecked: true }}
/>
<ListItem
lead={<Icon name="file-text" />}
title="Refunds.csv"
meta="24 KB"
check={{ name: "export", value: "refunds" }}
/>
<ListItem
lead={<Icon name="lock" />}
title="Payouts.csv"
description="Needs the finance role"
check={{ name: "export", value: "payouts", disabled: true }}
/>
</List>
</fieldset>
);
}Divided, with avatars
Without href, onSelect or check a row is plain content: no hover, no hit area, nothing to tab to.
- Priya Ramanpriya@larder.example
- Tomás Ferreiratomas@larder.example
- Ingrid Holmingrid@larder.example
- Kwame Mensahkwame@larder.example
Code
import { Avatar } from "@/components/equip/avatar";
import { List, ListItem } from "@/components/equip/list";
const team = [
{ name: "Priya Raman", email: "priya@larder.example", role: "Owner" },
{ name: "Tomás Ferreira", email: "tomas@larder.example", role: "Admin" },
{ name: "Ingrid Holm", email: "ingrid@larder.example", role: "Member" },
{ name: "Kwame Mensah", email: "kwame@larder.example", role: "Member" },
];
export default function Example() {
return (
<List dividers aria-label="Team members" style={{ maxWidth: 400 }}>
{team.map((p) => (
<ListItem
key={p.email}
lead={<Avatar name={p.name} alt="" />}
title={p.name}
description={p.email}
meta={p.role}
/>
))}
</List>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
dividers | boolean | false | Draws a line between rows and squares them off. |
density | "comfortable" | "compact" | "comfortable" | Compact rows are 28px high with smaller text. |
...rest | ComponentProps<"ul"> | none | Every native ul attribute, including aria-label. |
ListItem title | ReactNode | none | The row's name, and the name of its link, button or checkbox. |
ListItem lead | ReactNode | none | An icon or avatar before the text. |
ListItem description | ReactNode | none | A second line, linked to the row's control by aria-describedby. |
ListItem meta | ReactNode | none | Short facts at the end, such as a size or a time. Also read as a description. |
ListItem trail | ReactNode | none | A control at the end, such as a menu button. Sits above the row and keeps its own clicks. |
ListItem href | string | none | Makes the title a link that covers the row. |
ListItem onSelect | MouseEventHandler<HTMLButtonElement> | none | Makes the title a button that covers the row. |
ListItem check | Omit<ComponentProps<"input">, "type"> | none | Makes the row a native checkbox labelled by the title. Takes name, value, checked, defaultChecked, disabled, onChange. |
ListItem ...rest | Omit<ComponentProps<"li">, "title" | "onSelect"> | none | Every other native li attribute. |
Anatomy
| root | .eq-list | A ul. |
|---|---|---|
| item | .eq-list-item | An li. data-kind is link, button or check when the row is interactive. |
| check | .eq-list-check | Selection rows only. Holds the input and its mark. |
| check input | .eq-list-check-input | The native checkbox with appearance: none. |
| check mark | .eq-list-check-mark | Draws its stroke when checked. |
| lead | .eq-list-lead | |
| body | .eq-list-body | Holds the title and description. |
| title | .eq-list-title | A link, button, label or span. On an interactive row its ::after covers the row. |
| description | .eq-list-description | |
| meta | .eq-list-meta | |
| trail | .eq-list-trail |
Keyboard
| Tab | Moves to each row's link, button or checkbox, then to its trailing control. |
|---|---|
| Enter | Follows a link row or activates a button row. |
| Space | Activates a button row or toggles a selection row. |
Notes
- Each interactive row is one control, its title, so screen readers hear the title and not every word in the row; description and meta are read as its description.
- The focus ring is drawn around the whole row, and the trailing action sits above the stretched title so it stays a separate target.
- A selection row is a native checkbox, so it submits with a form; wrap the list in a fieldset with a legend to name the group.
- Rows without href, onSelect or check are plain content with no hover and no tab stop.
- Titles and descriptions truncate to one line; put anything that must be read in full in the title's own page.
- A client component only for useId and onSelect; it still renders in full on the server.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/list.tsx133 lines
"use client";
import { useId } from "react";
import type { ComponentProps, MouseEventHandler, ReactNode } from "react";
import "./list.css";
function join(...names: (string | false | undefined)[]) {
return names.filter(Boolean).join(" ");
}
/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
return node != null && node !== false && node !== "";
}
export type ListProps = ComponentProps<"ul"> & {
/** Draws a line between rows. */
dividers?: boolean;
/** Compact rows are 28px high, for dense panels and small tiles. */
density?: "comfortable" | "compact";
};
export function List({ dividers = false, density = "comfortable", className, ...rest }: ListProps) {
return (
<ul
{...rest}
className={join("eq-list", className)}
data-dividers={dividers || undefined}
data-density={density}
/>
);
}
export type ListItemProps = Omit<ComponentProps<"li">, "title" | "onSelect"> & {
/** The row's name. Becomes the one link, button or checkbox label for the whole row. */
title: ReactNode;
/** An icon or avatar before the text. */
lead?: ReactNode;
/** A second line under the title. */
description?: ReactNode;
/** Short facts at the end, such as a size or a time. */
meta?: ReactNode;
/** A control at the very end, such as a menu button. Stays clickable above the row. */
trail?: ReactNode;
/** Makes the row a link. */
href?: string;
/** Makes the row a button. */
onSelect?: MouseEventHandler<HTMLButtonElement>;
/** Makes the row a checkbox. Takes every native input attribute: name, value, checked, defaultChecked, onChange. */
check?: Omit<ComponentProps<"input">, "type">;
};
export function ListItem({
title,
lead,
description,
meta,
trail,
href,
onSelect,
check,
className,
...rest
}: ListItemProps) {
const id = useId();
const descriptionId = present(description) ? `${id}-description` : undefined;
const metaId = present(meta) ? `${id}-meta` : undefined;
const described = [descriptionId, metaId].filter(Boolean).join(" ") || undefined;
const kind = check ? "check" : href != null ? "link" : onSelect ? "button" : undefined;
const inputId = check?.id ?? `${id}-input`;
let name: ReactNode;
if (kind === "check") {
name = (
<label className="eq-list-title" htmlFor={inputId}>
{title}
</label>
);
} else if (kind === "link") {
name = (
<a className="eq-list-title" href={href} aria-describedby={described}>
{title}
</a>
);
} else if (kind === "button") {
name = (
<button type="button" className="eq-list-title" aria-describedby={described} onClick={onSelect}>
{title}
</button>
);
} else {
name = <span className="eq-list-title">{title}</span>;
}
return (
<li
{...rest}
className={join("eq-list-item", className)}
data-kind={kind}
data-disabled={check?.disabled || undefined}
>
{check && (
<span className="eq-list-check">
<input
aria-describedby={described}
{...check}
type="checkbox"
id={inputId}
className={join("eq-list-check-input", check.className)}
/>
<svg className="eq-list-check-mark" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m4.5 8.25 2.25 2.25 4.75-5" pathLength="1" />
</svg>
</span>
)}
{present(lead) && <span className="eq-list-lead">{lead}</span>}
<span className="eq-list-body">
{name}
{present(description) && (
<span className="eq-list-description" id={descriptionId}>
{description}
</span>
)}
</span>
{present(meta) && (
<span className="eq-list-meta" id={metaId}>
{meta}
</span>
)}
{present(trail) && <span className="eq-list-trail">{trail}</span>}
</li>
);
}components/equip/list.css227 lines
@layer equip {
.eq-list {
display: grid;
gap: 2px;
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
}
.eq-list[data-dividers] {
gap: 0;
}
.eq-list-item {
--_pad-block: var(--eq-space-2);
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--eq-space-3);
min-width: 0;
min-height: var(--eq-h-m);
padding: var(--_pad-block) var(--eq-space-2);
border-radius: var(--eq-radius-s);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.35 var(--eq-font);
transition:
background-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-list[data-density="compact"] > .eq-list-item {
--_pad-block: 2px;
gap: var(--eq-space-2);
min-height: var(--eq-h-s);
font-size: var(--eq-text-s);
}
/* With dividers the rows are bands, so they lose their rounding and the line does the separating. */
.eq-list[data-dividers] > .eq-list-item {
border-radius: 0;
}
.eq-list[data-dividers] > .eq-list-item + .eq-list-item {
border-block-start: 1px solid var(--eq-line);
}
.eq-list-lead {
display: inline-flex;
flex: none;
align-items: center;
color: var(--eq-ink-2);
}
.eq-list-body {
display: flex;
flex: 1;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.eq-list-title,
.eq-list-description {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-list-title {
display: block;
margin: 0;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
font-weight: var(--eq-weight-strong);
text-align: start;
text-decoration: none;
}
.eq-list-description {
color: var(--eq-ink-2);
font-size: var(--eq-text-s);
}
.eq-list-meta {
flex: none;
color: var(--eq-ink-3);
font-size: var(--eq-text-s);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.eq-list-trail {
display: inline-flex;
flex: none;
align-items: center;
gap: var(--eq-space-1);
margin-block: calc(var(--_pad-block) * -1);
margin-inline-end: calc(var(--eq-space-1) * -1);
}
/* One hit area: the title's pseudo-element covers the row. Everything interactive in
the trail, and the checkbox, sit above it and keep their own clicks. */
.eq-list-item[data-kind] .eq-list-title {
cursor: pointer;
}
.eq-list-item[data-kind] .eq-list-title::after {
content: "";
position: absolute;
inset: 0;
}
.eq-list-item[data-kind] :is(.eq-list-trail, .eq-list-check) {
position: relative;
z-index: 1;
}
.eq-list-item[data-kind] .eq-list-title:focus-visible {
outline: none;
}
.eq-list-item:has(.eq-list-title:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
@media (hover: hover) {
.eq-list-item[data-kind]:not([data-disabled]):hover {
background: var(--eq-tint);
}
}
.eq-list-item[data-kind]:not([data-disabled]):has(.eq-list-title:active) {
scale: var(--eq-press);
}
/* Selection rows: a native checkbox drawn to the shared check spec. */
.eq-list-check {
position: relative;
flex: none;
display: block;
width: 16px;
height: 16px;
}
.eq-list-check-input {
appearance: none;
box-sizing: border-box;
display: block;
width: 16px;
height: 16px;
margin: 0;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-s);
background: var(--eq-raised);
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
border-color var(--eq-dur) var(--eq-ease);
}
.eq-list-check-input:checked {
border-color: var(--eq-solid);
background: var(--eq-solid);
}
.eq-list-check-input:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-list-check-mark {
position: absolute;
inset: 0;
width: 16px;
height: 16px;
color: var(--eq-solid-ink);
pointer-events: none;
opacity: 0;
transition: opacity var(--eq-dur) var(--eq-ease);
}
/* Drawn along its stroke, as Checkbox does: the dash is twice the path so the spring can overshoot. */
.eq-list-check-mark > path {
stroke-dasharray: 2 3;
stroke-dashoffset: 2;
transition: stroke-dashoffset var(--eq-dur) var(--eq-ease);
}
.eq-list-check-input:checked ~ .eq-list-check-mark {
opacity: 1;
}
.eq-list-check-input:checked ~ .eq-list-check-mark > path {
stroke-dashoffset: 1;
transition: stroke-dashoffset var(--eq-dur-move) var(--eq-spring);
}
.eq-list-item:has(.eq-list-check-input:checked) {
background: var(--eq-tint);
}
@media (hover: hover) {
.eq-list-item:hover .eq-list-check-input:not(:checked, :disabled) {
border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
}
.eq-list-item:not([data-disabled]):has(.eq-list-check-input:checked):hover {
background: var(--eq-tint-strong);
}
}
.eq-list-item[data-disabled] {
opacity: 0.45;
}
.eq-list-item[data-disabled] :is(.eq-list-title, .eq-list-check-input) {
cursor: not-allowed;
}
}