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

  1. Create a workspace, done

    Your workspace holds projects, members and billing.

  2. Connect your domain

    Add a CNAME record pointing to edge.example.com.

  3. 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

  1. Add a payment method, done

    Visa ending in 4242, expires 08/28.

  2. Set a billing contact

    Invoices and payment failures go to this address.

  3. Choose a spending limit

    Usage stops at the limit until someone raises it.

  4. 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

  1. Verify the sending domain, done

    SPF and DKIM records were found for mail.example.com.

  2. Send a test campaign, done

    Delivered to 3 seed inboxes with no spam flags.

  3. 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

PropTypeDefaultWhat it does
titlestringnoneHeading of the list and name of its progress bar.
...restComponentProps<"section">noneEvery native section attribute. Children are ChecklistItem elements.
ChecklistItem titlestringnoneThe task.
ChecklistItem donebooleanfalseStrikes the task, fills its check and keeps it collapsed.
ChecklistItem defaultOpenbooleanfalseStarts expanded. Ignored when done.
ChecklistItem ...restComponentProps<"details">noneEvery native details attribute, including name. Children are the description and one action.

Anatomy

root.eq-checklistA section; data-complete when every task is done.
header.eq-checklist-header
title.eq-checklist-titleAn h2.
count.eq-checklist-countReads 2 of 5 done, or All 5 done.
bar.eq-checklist-barrole="progressbar" with the same values.
fill.eq-checklist-fill
list.eq-checklist-list
row.eq-checklist-rowThe li around each item.
item.eq-checklist-itemA 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-srSays done after a finished task's name.

Keyboard

EnterSpaceOn a task, expands or collapses it; expanding closes the open one.
TabShift+TabMoves 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index