Flows
Checklist
An onboarding checklist with a progress count and expandable tasks, one open at a time.
- Built on
<details name><summary>- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 2.2 kB
- Dependencies
- React
Get started
Create a workspace, done
Your workspace holds projects, members and billing.
Connect your domain
Add a CNAME record pointing to edge.example.com.
Invite your team
Teammates get access to every project in the workspace.
Code
import { Button } from "@/components/equip/button";
import { Checklist, ChecklistItem } from "@/components/equip/checklist";
export default function Demo() {
return (
<Checklist title="Get started" style={{ width: 224 }}>
<ChecklistItem title="Create a workspace" done>
<p>Your workspace holds projects, members and billing.</p>
</ChecklistItem>
<ChecklistItem title="Connect your domain">
<p>Add a CNAME record pointing to edge.example.com.</p>
<Button size="s" variant="outline">Add record</Button>
</ChecklistItem>
<ChecklistItem title="Invite your team">
<p>Teammates get access to every project in the workspace.</p>
<Button size="s" variant="outline">Invite people</Button>
</ChecklistItem>
</Checklist>
);
}HTMLworks in any framework, with the part's CSS
<section style="width:224px" class="eq-checklist">
<div class="eq-checklist-header">
<h2 class="eq-checklist-title">Get started</h2>
<span class="eq-checklist-count" aria-hidden="true">1 of 3 done</span>
</div>
<div class="eq-checklist-bar" role="progressbar" aria-label="Get started" aria-valuemin="0" aria-valuemax="3" aria-valuenow="1" aria-valuetext="1 of 3 done" style="--_done:0.3333333333333333">
<span class="eq-checklist-fill"></span>
</div>
<ol class="eq-checklist-list">
<li class="eq-checklist-row">
<details name="eq-checklist-get-started" class="eq-checklist-item" data-done="true">
<summary class="eq-checklist-summary">
<span class="eq-checklist-check" aria-hidden="true">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m2.5 6.2 2.3 2.3 4.7-5" />
</svg>
</span>
<span class="eq-checklist-task">Create a workspace<span class="eq-checklist-sr">, done</span></span>
<svg class="eq-checklist-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 4 4 4-4 4" />
</svg>
</summary>
<div class="eq-checklist-content">
<p>Your workspace holds projects, members and billing.</p>
</div>
</details>
</li>
<li class="eq-checklist-row">
<details name="eq-checklist-get-started" class="eq-checklist-item">
<summary class="eq-checklist-summary">
<span class="eq-checklist-check" aria-hidden="true"></span>
<span class="eq-checklist-task">Connect your domain</span>
<svg class="eq-checklist-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 4 4 4-4 4" />
</svg>
</summary>
<div class="eq-checklist-content">
<p>Add a CNAME record pointing to edge.example.com.</p>
<button type="button" class="eq-button" data-variant="outline" data-size="s">
<span class="eq-button-label">Add record</span>
</button>
</div>
</details>
</li>
<li class="eq-checklist-row">
<details name="eq-checklist-get-started" class="eq-checklist-item">
<summary class="eq-checklist-summary">
<span class="eq-checklist-check" aria-hidden="true"></span>
<span class="eq-checklist-task">Invite your team</span>
<svg class="eq-checklist-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 4 4 4-4 4" />
</svg>
</summary>
<div class="eq-checklist-content">
<p>Teammates get access to every project in the workspace.</p>
<button type="button" class="eq-button" data-variant="outline" data-size="s">
<span class="eq-button-label">Invite people</span>
</button>
</div>
</details>
</li>
</ol>
</section>Examples
Tasks with an action
Only one task is open at a time, because the items share a details name.
Set up billing
Add a payment method, done
Visa ending in 4242, expires 08/28.
Set a billing contact
Invoices and payment failures go to this address.
Choose a spending limit
Usage stops at the limit until someone raises it.
Download your first invoice
The first invoice is issued on the 1st of next month.
Code
import { Button } from "@/components/equip/button";
import { Checklist, ChecklistItem } from "@/components/equip/checklist";
export default function Example() {
return (
<Checklist title="Set up billing" style={{ width: "100%", maxWidth: 400 }}>
<ChecklistItem title="Add a payment method" done>
<p>Visa ending in 4242, expires 08/28.</p>
</ChecklistItem>
<ChecklistItem title="Set a billing contact" defaultOpen>
<p>Invoices and payment failures go to this address.</p>
<Button size="s">Add contact</Button>
</ChecklistItem>
<ChecklistItem title="Choose a spending limit">
<p>Usage stops at the limit until someone raises it.</p>
<Button size="s" variant="outline">Set limit</Button>
</ChecklistItem>
<ChecklistItem title="Download your first invoice">
<p>The first invoice is issued on the 1st of next month.</p>
<Button size="s" variant="outline">View invoices</Button>
</ChecklistItem>
</Checklist>
);
}HTMLworks in any framework, with the part's CSS
<section style="width:100%;max-width:400px" class="eq-checklist">
<div class="eq-checklist-header">
<h2 class="eq-checklist-title">Set up billing</h2>
<span class="eq-checklist-count" aria-hidden="true">1 of 4 done</span>
</div>
<div class="eq-checklist-bar" role="progressbar" aria-label="Set up billing" aria-valuemin="0" aria-valuemax="4" aria-valuenow="1" aria-valuetext="1 of 4 done" style="--_done:0.25">
<span class="eq-checklist-fill"></span>
</div>
<ol class="eq-checklist-list">
<li class="eq-checklist-row">
<details name="eq-checklist-set-up-billing" class="eq-checklist-item" data-done="true">
<summary class="eq-checklist-summary">
<span class="eq-checklist-check" aria-hidden="true">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m2.5 6.2 2.3 2.3 4.7-5" />
</svg>
</span>
<span class="eq-checklist-task">Add a payment method<span class="eq-checklist-sr">, done</span></span>
<svg class="eq-checklist-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 4 4 4-4 4" />
</svg>
</summary>
<div class="eq-checklist-content">
<p>Visa ending in 4242, expires 08/28.</p>
</div>
</details>
</li>
<li class="eq-checklist-row">
<details open name="eq-checklist-set-up-billing" class="eq-checklist-item">
<summary class="eq-checklist-summary">
<span class="eq-checklist-check" aria-hidden="true"></span>
<span class="eq-checklist-task">Set a billing contact</span>
<svg class="eq-checklist-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 4 4 4-4 4" />
</svg>
</summary>
<div class="eq-checklist-content">
<p>Invoices and payment failures go to this address.</p>
<button type="button" class="eq-button" data-variant="solid" data-size="s">
<span class="eq-button-label">Add contact</span>
</button>
</div>
</details>
</li>
<li class="eq-checklist-row">
<details name="eq-checklist-set-up-billing" class="eq-checklist-item">
<summary class="eq-checklist-summary">
<span class="eq-checklist-check" aria-hidden="true"></span>
<span class="eq-checklist-task">Choose a spending limit</span>
<svg class="eq-checklist-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 4 4 4-4 4" />
</svg>
</summary>
<div class="eq-checklist-content">
<p>Usage stops at the limit until someone raises it.</p>
<button type="button" class="eq-button" data-variant="outline" data-size="s">
<span class="eq-button-label">Set limit</span>
</button>
</div>
</details>
</li>
<li class="eq-checklist-row">
<details name="eq-checklist-set-up-billing" class="eq-checklist-item">
<summary class="eq-checklist-summary">
<span class="eq-checklist-check" aria-hidden="true"></span>
<span class="eq-checklist-task">Download your first invoice</span>
<svg class="eq-checklist-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 4 4 4-4 4" />
</svg>
</summary>
<div class="eq-checklist-content">
<p>The first invoice is issued on the 1st of next month.</p>
<button type="button" class="eq-button" data-variant="outline" data-size="s">
<span class="eq-button-label">View invoices</span>
</button>
</div>
</details>
</li>
</ol>
</section>All done
When every task is done the count reads as finished and the bar is full; the tasks stay for reference.
Launch checklist
Verify the sending domain, done
SPF and DKIM records were found for mail.example.com.
Send a test campaign, done
Delivered to 3 seed inboxes with no spam flags.
Import contacts, done
4,812 contacts imported from contacts-2026.csv.
Code
import { Checklist, ChecklistItem } from "@/components/equip/checklist";
export default function Example() {
return (
<Checklist title="Launch checklist" style={{ width: "100%", maxWidth: 400 }}>
<ChecklistItem title="Verify the sending domain" done>
<p>SPF and DKIM records were found for mail.example.com.</p>
</ChecklistItem>
<ChecklistItem title="Send a test campaign" done>
<p>Delivered to 3 seed inboxes with no spam flags.</p>
</ChecklistItem>
<ChecklistItem title="Import contacts" done>
<p>4,812 contacts imported from contacts-2026.csv.</p>
</ChecklistItem>
</Checklist>
);
}HTMLworks in any framework, with the part's CSS
<section style="width:100%;max-width:400px" class="eq-checklist" data-complete="true">
<div class="eq-checklist-header">
<h2 class="eq-checklist-title">Launch checklist</h2>
<span class="eq-checklist-count" aria-hidden="true">All 3 done</span>
</div>
<div class="eq-checklist-bar" role="progressbar" aria-label="Launch checklist" aria-valuemin="0" aria-valuemax="3" aria-valuenow="3" aria-valuetext="All 3 done" style="--_done:1">
<span class="eq-checklist-fill"></span>
</div>
<ol class="eq-checklist-list">
<li class="eq-checklist-row">
<details name="eq-checklist-launch-checklist" class="eq-checklist-item" data-done="true">
<summary class="eq-checklist-summary">
<span class="eq-checklist-check" aria-hidden="true">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m2.5 6.2 2.3 2.3 4.7-5" />
</svg>
</span>
<span class="eq-checklist-task">Verify the sending domain<span class="eq-checklist-sr">, done</span></span>
<svg class="eq-checklist-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 4 4 4-4 4" />
</svg>
</summary>
<div class="eq-checklist-content">
<p>SPF and DKIM records were found for mail.example.com.</p>
</div>
</details>
</li>
<li class="eq-checklist-row">
<details name="eq-checklist-launch-checklist" class="eq-checklist-item" data-done="true">
<summary class="eq-checklist-summary">
<span class="eq-checklist-check" aria-hidden="true">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m2.5 6.2 2.3 2.3 4.7-5" />
</svg>
</span>
<span class="eq-checklist-task">Send a test campaign<span class="eq-checklist-sr">, done</span></span>
<svg class="eq-checklist-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 4 4 4-4 4" />
</svg>
</summary>
<div class="eq-checklist-content">
<p>Delivered to 3 seed inboxes with no spam flags.</p>
</div>
</details>
</li>
<li class="eq-checklist-row">
<details name="eq-checklist-launch-checklist" class="eq-checklist-item" data-done="true">
<summary class="eq-checklist-summary">
<span class="eq-checklist-check" aria-hidden="true">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m2.5 6.2 2.3 2.3 4.7-5" />
</svg>
</span>
<span class="eq-checklist-task">Import contacts<span class="eq-checklist-sr">, done</span></span>
<svg class="eq-checklist-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 4 4 4-4 4" />
</svg>
</summary>
<div class="eq-checklist-content">
<p>4,812 contacts imported from contacts-2026.csv.</p>
</div>
</details>
</li>
</ol>
</section>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title | string | none | Heading of the list and name of its progress bar. |
...rest | ComponentProps<"section"> | none | Every native section attribute. Children are ChecklistItem elements. |
ChecklistItem title | string | none | The task. |
ChecklistItem done | boolean | false | Strikes the task, fills its check and keeps it collapsed. |
ChecklistItem defaultOpen | boolean | false | Starts expanded. Ignored when done. |
ChecklistItem ...rest | ComponentProps<"details"> | none | Every native details attribute, including name. Children are the description and one action. |
Anatomy
| root | .eq-checklist | A section; data-complete when every task is done. |
|---|---|---|
| header | .eq-checklist-header | |
| title | .eq-checklist-title | An h2. |
| count | .eq-checklist-count | Reads 2 of 5 done, or All 5 done. |
| bar | .eq-checklist-bar | role="progressbar" with the same values. |
| fill | .eq-checklist-fill | |
| list | .eq-checklist-list | |
| row | .eq-checklist-row | The li around each item. |
| item | .eq-checklist-item | A details element; data-done when done. |
| summary | .eq-checklist-summary | |
| check | .eq-checklist-check | |
| task | .eq-checklist-task | |
| chevron | .eq-checklist-chevron | |
| content | .eq-checklist-content | |
| visually hidden | .eq-checklist-sr | Says done after a finished task's name. |
Keyboard
| EnterSpace | On a task, expands or collapses it; expanding closes the open one. |
|---|---|
| TabShift+Tab | Moves between tasks and the action of the open task. |
Notes
- Renders on the server with no client JavaScript; the count is computed from the done props of its ChecklistItem children.
- Only direct ChecklistItem children are counted, not items inside wrappers or fragments.
- The items share a details name derived from the title, so two checklists with the same title on one page share one open task; pass name on the items to separate them.
- The progress bar carries the count for assistive tech, so the visible count is hidden from it to avoid hearing it twice.
- Done tasks say done after their name, since the strike-through is visual only.
- The title is an h2; wrap the checklist in a section of your own if the page needs a different level.
- Exclusive details with name need Chrome 120, Firefox 130 or Safari 17.2; elsewhere several tasks can be open at once.
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/checklist.tsx120 lines
import {
Children,
cloneElement,
isValidElement,
type ComponentProps,
type CSSProperties,
type ReactElement,
} from "react";
import "./checklist.css";
export type ChecklistItemProps = ComponentProps<"details"> & {
title: string;
/** Strikes the title and keeps the task collapsed. */
done?: boolean;
defaultOpen?: boolean;
};
export type ChecklistProps = ComponentProps<"section"> & {
title: string;
};
function isItem(node: unknown): node is ReactElement<ChecklistItemProps> {
return isValidElement(node) && node.type === ChecklistItem;
}
/**
* Counts done tasks from its ChecklistItem children. The items share a
* details name, so opening one closes the others.
*/
export function Checklist({ title, className, children, ...rest }: ChecklistProps) {
const items = Children.toArray(children).filter(isItem);
const total = items.length;
const done = items.filter((item) => item.props.done).length;
const complete = total > 0 && done === total;
const group = `eq-checklist-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
const status = complete ? `All ${total} done` : `${done} of ${total} done`;
return (
<section
{...rest}
className={["eq-checklist", className].filter(Boolean).join(" ")}
data-complete={complete || undefined}
>
<div className="eq-checklist-header">
<h2 className="eq-checklist-title">{title}</h2>
<span className="eq-checklist-count" aria-hidden="true">
{status}
</span>
</div>
<div
className="eq-checklist-bar"
role="progressbar"
aria-label={title}
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={done}
aria-valuetext={status}
style={{ "--_done": total ? done / total : 0 } as CSSProperties}
>
<span className="eq-checklist-fill" />
</div>
<ol className="eq-checklist-list">
{Children.map(children, (child) =>
isItem(child) ? (
<li className="eq-checklist-row">
{child.props.name ? child : cloneElement(child, { name: group })}
</li>
) : (
child
),
)}
</ol>
</section>
);
}
export function ChecklistItem({
title,
done = false,
defaultOpen = false,
className,
children,
...rest
}: ChecklistItemProps) {
return (
<details
open={!done && defaultOpen}
{...rest}
className={["eq-checklist-item", className].filter(Boolean).join(" ")}
data-done={done || undefined}
>
<summary className="eq-checklist-summary">
<span className="eq-checklist-check" aria-hidden="true">
{done && (
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="m2.5 6.2 2.3 2.3 4.7-5" />
</svg>
)}
</span>
<span className="eq-checklist-task">
{title}
{done && <span className="eq-checklist-sr">, done</span>}
</span>
<svg
className="eq-checklist-chevron"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m6 4 4 4-4 4" />
</svg>
</summary>
<div className="eq-checklist-content">{children}</div>
</details>
);
}components/equip/checklist.css159 lines
@layer equip {
.eq-checklist {
display: grid;
/* Long task names truncate instead of widening the list past its container. */
grid-template-columns: minmax(0, 1fr);
min-width: 0;
gap: var(--eq-space-2);
margin: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.5 var(--eq-font);
}
.eq-checklist-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--eq-space-3);
}
.eq-checklist-title {
margin: 0;
font-size: var(--eq-text-m);
font-weight: var(--eq-weight-strong);
line-height: 1.3;
letter-spacing: -0.005em;
}
.eq-checklist-count {
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.eq-checklist-bar {
height: 4px;
overflow: hidden;
border-radius: var(--eq-radius-full);
background: var(--eq-tint-strong);
}
.eq-checklist-fill {
display: block;
width: calc(var(--_done, 0) * 100%);
height: 100%;
border-radius: inherit;
background: var(--eq-solid);
}
.eq-checklist-list {
display: grid;
grid-template-columns: minmax(0, 1fr);
margin: 0;
padding: 0;
list-style: none;
}
.eq-checklist-summary {
display: flex;
align-items: center;
gap: var(--eq-space-2);
min-height: var(--eq-h-s);
padding-inline: var(--eq-space-2);
border-radius: var(--eq-radius-s);
list-style: none;
cursor: pointer;
user-select: none;
}
.eq-checklist-summary::-webkit-details-marker {
display: none;
}
@media (hover: hover) {
.eq-checklist-summary:hover {
background: var(--eq-tint);
}
}
.eq-checklist-summary:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-checklist-check {
flex: none;
display: inline-grid;
place-items: center;
box-sizing: border-box;
width: 16px;
height: 16px;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-full);
background: var(--eq-raised);
}
.eq-checklist-check svg {
width: 12px;
height: 12px;
}
.eq-checklist-item[data-done] .eq-checklist-check {
border-color: var(--eq-solid);
background: var(--eq-solid);
color: var(--eq-solid-ink);
}
.eq-checklist-task {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-checklist-item[data-done] .eq-checklist-task {
color: var(--eq-ink-3);
text-decoration: line-through;
}
.eq-checklist-chevron {
flex: none;
width: 16px;
height: 16px;
color: var(--eq-ink-3);
transition: rotate var(--eq-dur) var(--eq-ease);
}
.eq-checklist-item[open] .eq-checklist-chevron {
rotate: 90deg;
}
.eq-checklist-content {
display: grid;
justify-items: start;
gap: var(--eq-space-2);
padding: var(--eq-space-1) var(--eq-space-2) var(--eq-space-3) calc(var(--eq-space-2) * 2 + 16px);
color: var(--eq-ink-2);
font-size: var(--eq-text-s);
}
.eq-checklist-content > p {
margin: 0;
}
.eq-checklist[data-complete] .eq-checklist-count {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
}
.eq-checklist-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}