component

List

A read-only list of rows — backups, services, settings links, certificate details. List is card-based: it frames itself in its own Card, so a heading, header actions and a footer come through the card prop, and in a flush card the header lines up with the row text automatically.

It has two layouts. stacked (the default) renders a semantic <ul role="list"> whose rows are a leading addon, a Banner title + description, a tabular value and a trailing cell. pairs renders a native <dl> of label → value rows. Rows come from the items array; List never has selection or keyboard navigation — for a pickable list use ListBox.

Stacked rows

Each row’s title and description render through a Banner with headingLevel: "none", so rows add nothing to the document outline. The list is a CSS grid and every row a subgrid over it, so values line up across rows. valueAlign moves the value inside its column, and valueColumn decides what a trailing cell does to it (see Value column).

Nightly backups

Run of 26 September

  • orders-db

    Postgres · finished 02:14

    48.2 GB OK
  • media-bucket

    Object storage · finished 02:41

    1.3 TB OK
  • analytics-warehouse

    ClickHouse · timed out at 03:05

    — Failed
  • search-index

    OpenSearch · finished 03:12

    212 MB OK
---
// GENERATED from packages/demos/list/stacked.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
import Badge from "@pindoba/astro-badge";
---

<List
  card={{ heading: "Nightly backups", subheading: "Run of 26 September" }}
  items={[
    {
      id: "orders",
      title: "orders-db",
      description: "Postgres · finished 02:14",
      value: "48.2 GB",
    },
    {
      id: "media",
      title: "media-bucket",
      description: "Object storage · finished 02:41",
      value: "1.3 TB",
    },
    {
      id: "warehouse",
      title: "analytics-warehouse",
      description: "ClickHouse · timed out at 03:05",
      value: "—",
    },
    {
      id: "search",
      title: "search-index",
      description: "OpenSearch · finished 03:12",
      value: "212 MB",
    },
  ]}
>
  <Badge slot="orders:trailing" size="sm" feedback="success">
    OK
  </Badge>
  <Badge slot="media:trailing" size="sm" feedback="success">
    OK
  </Badge>
  <Badge slot="warehouse:trailing" size="sm" feedback="danger">
    Failed
  </Badge>
  <Badge slot="search:trailing" size="sm" feedback="success">
    OK
  </Badge>
</List>

Leading addons

leading renders in the row’s [data-affix] cell. Each row publishes its Control Var Contract size (data-control-size), so a Stamp — or a Badge, or a Kbd — sizes itself from the row’s --affix-* vocabulary without a size prop. In Svelte item content is a snippet, in React a node, in Vue a VNode; in Astro rich content goes through per-row named slots (see Rich rows).

Service latency

p95 over the last hour

  • Webhooks

    Outbound delivery

    42 ms
  • Email relay

    Transactional sends

    180 ms
  • Payments

    Card authorisation

    311 ms
  • Primary database

    Read replicas

    9 ms
---
// GENERATED from packages/demos/list/leading.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
import Stamp from "@pindoba/astro-stamp";
import { Webhook, Mail, CreditCard, Database } from "@lucide/astro";
---

<List
  card={{
    heading: "Service latency",
    subheading: "p95 over the last hour",
  }}
  items={[
    {
      id: "hooks",
      title: "Webhooks",
      description: "Outbound delivery",
      value: "42 ms",
    },
    {
      id: "mail",
      title: "Email relay",
      description: "Transactional sends",
      value: "180 ms",
    },
    {
      id: "pay",
      title: "Payments",
      description: "Card authorisation",
      value: "311 ms",
    },
    {
      id: "db",
      title: "Primary database",
      description: "Read replicas",
      value: "9 ms",
    },
  ]}
>
  <Stamp slot="hooks:leading" feedback="primary">
    <Webhook />
  </Stamp>
  <Stamp slot="mail:leading" feedback="success">
    <Mail />
  </Stamp>
  <Stamp slot="pay:leading" feedback="warning">
    <CreditCard />
  </Stamp>
  <Stamp slot="db:leading">
    <Database />
  </Stamp>
</List>

Rich rows

title and description take rich content too — a snippet in Svelte, a node in React, a VNode in Vue — so a row can carry a method Badge beside a monospace route, or a Progress bar as its description line. Rich content keeps the row’s typography and muted-description tokens.

In Astro items are string-only; rich content goes through per-row named slots keyed by the item id:

  • stacked rows: <id>:title, <id>:description, <id>:leading, <id>:value, <id>:trailing, and <id> for a whole custom row (custom: true);
  • pairs: <id>:term, <id>:value, <id>:description, <id>:trailing.

A string field wins over its slot. One Astro limitation: slotted children can’t be emitted from inside a .map() — the compiler hoists slot="…" children out of the callback scope, so the item variable is undefined (a ReferenceError). Write each row’s slotted content out explicitly, as the demos do.

Latency budget

p95, last 24 hours

  • GET/v1/search
    82% of budget 3 regions
    212 ms
  • POST /v1/orders
    41% of budget 2 regions
    96 ms
---
// GENERATED from packages/demos/list/rich-rows.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
import Badge from "@pindoba/astro-badge";
import { css } from "@pindoba/styled-system/css";

const route = css({ fontFamily: "mono", fontSize: "sm" });
const meta = css({
  display: "inline-flex",
  alignItems: "center",
  gap: "2xs",
});
---

<List
  card={{ heading: "Latency budget", subheading: "p95, last 24 hours" }}
  items={[
    {
      id: "search",
      value: "212 ms",
    },
    {
      id: "orders",
      value: "96 ms",
    },
  ]}
>
  <span slot="search:title" class={meta}>
    <Badge size="sm">GET</Badge>
    <code class={route}>/v1/search</code>
  </span>
  <span slot="search:description" class={meta}>
    <Badge size="sm" feedback="warning">
      82% of budget
    </Badge>
    3 regions
  </span>
  <span slot="orders:title" class={meta}>
    <Badge size="sm" feedback="primary">
      POST
    </Badge>
    <code class={route}>/v1/orders</code>
  </span>
  <span slot="orders:description" class={meta}>
    <Badge size="sm" feedback="success">
      41% of budget
    </Badge>
    2 regions
  </span>
</List>

Row states

A stacked row takes muted, feedback and emphasis. muted quiets the title, value and ghost icons to the row palette’s solved muted text token — never an opacity fade — for a cleared gate next to an open one. feedback gives the row a palette; alone it tints the row (emphasis defaults to "secondary"), emphasis: "ghost" recolours only the text and emphasis: "primary" fills the row with the accent surface and contrast text. The states are purely visual.

Launch gates

Mobile app 4.2

  • Legal review

    Approved by counsel

    Sep 18
  • Performance budget

    Cold start under 1.2 s

    Sep 21
  • Accessibility audit

    2 findings open

    Due Sep 29
  • Store listing

    Waiting on screenshots

    Blocked
---
// GENERATED from packages/demos/list/states.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
import Stamp from "@pindoba/astro-stamp";
import { Check, CircleDashed, Lock } from "@lucide/astro";
---

<List
  card={{ heading: "Launch gates", subheading: "Mobile app 4.2" }}
  items={[
    {
      id: "legal",
      title: "Legal review",
      description: "Approved by counsel",
      value: "Sep 18",
      muted: true,
    },
    {
      id: "perf",
      title: "Performance budget",
      description: "Cold start under 1.2 s",
      value: "Sep 21",
      muted: true,
    },
    {
      id: "a11y",
      title: "Accessibility audit",
      description: "2 findings open",
      value: "Due Sep 29",
      feedback: "warning",
    },
    {
      id: "store",
      title: "Store listing",
      description: "Waiting on screenshots",
      value: "Blocked",
      feedback: "danger",
      emphasis: "primary",
    },
  ]}
>
  <Stamp slot="legal:leading" emphasis="ghost">
    <Check />
  </Stamp>
  <Stamp slot="perf:leading" emphasis="ghost">
    <Check />
  </Stamp>
  <Stamp slot="a11y:leading" emphasis="ghost">
    <CircleDashed />
  </Stamp>
  <Stamp slot="store:leading" emphasis="ghost">
    <Lock />
  </Stamp>
</List>

Value column

The value and the trailing cell are separate columns. With valueColumn="independent" (the default) a row without a trailing cell runs its value to the row’s end edge, so one row’s trailing Badge doesn’t pull every other row’s value in by the badge width. valueColumn="shared" keeps every value in one column — values share an end edge and rows without a trailing cell leave that column empty. Both keep the subgrid alignment.

Queue depth

independent (default)

  • email-outbound
    1,204
  • thumbnails
    86 Throttled
  • webhooks
    12

Queue depth

shared

  • email-outbound
    1,204
  • thumbnails
    86 Throttled
  • webhooks
    12
---
// GENERATED from packages/demos/list/value-column.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
import Badge from "@pindoba/astro-badge";
import { css } from "@pindoba/styled-system/css";

const queues = [
  { id: "email", title: "email-outbound", value: "1,204" },
  {
    id: "thumbs",
    title: "thumbnails",
    value: "86",
  },
  { id: "webhooks", title: "webhooks", value: "12" },
];
---

<div
  class={css({
    display: "grid",
    gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 18rem), 1fr))",
    gap: "md",
    width: "100%",
  })}
>
  <List
    card={{ heading: "Queue depth", subheading: "independent (default)" }}
    items={queues}
  >
    <Badge slot="thumbs:trailing" feedback="warning" size="sm">
      Throttled
    </Badge>
  </List>
  <List
    valueColumn="shared"
    card={{ heading: "Queue depth", subheading: "shared" }}
    items={queues}
  >
    <Badge slot="thumbs:trailing" feedback="warning" size="sm">
      Throttled
    </Badge>
  </List>
</div>

Trailing placement

The trailing cell is its own column, as wide as the widest trailing cell in the list. When that is wide — a pair of Buttons — the title column is what gives, and on a phone the title can end up one word per line. trailingPlacement moves the trailing cell:

  • "end" (the default): its own column at the row’s end.
  • "below": on a line under the title, starting at the title’s edge and wrapping if it is wider than the row. The trailing column collapses and each value runs to the row’s end.
  • "auto": at the end until the list is narrower than 480px, then below. It is a container query on the list, so the list becomes an inline-size container: its width comes from where it sits, never from its rows. Don’t use it on a list that should shrink to fit its content (in an inline or shrink-to-fit parent).
<List {items} card={false} trailingPlacement="auto" />

Failed jobs

trailingPlacement="below"

  • Deploy web-frontend

    Failed at step 4 of 6 · health check

    3m 12s
  • Migrate billing database

    Timed out waiting for a table lock

    30m 00s
  • Nightly backup

    Snapshot upload stalled at 82%

    12m 40s

Failed jobs

"auto" · wide list

  • Deploy web-frontend

    Failed at step 4 of 6 · health check

    3m 12s
  • Migrate billing database

    Timed out waiting for a table lock

    30m 00s
  • Nightly backup

    Snapshot upload stalled at 82%

    12m 40s

Failed jobs

"auto" · 26rem wide

  • Deploy web-frontend

    Failed at step 4 of 6 · health check

    3m 12s
  • Migrate billing database

    Timed out waiting for a table lock

    30m 00s
  • Nightly backup

    Snapshot upload stalled at 82%

    12m 40s
---
// GENERATED from packages/demos/list/trailing-placement.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
import Button from "@pindoba/astro-button";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

const layout = stack({ gap: "md", width: "100%" });
const autoRow = css({
  display: "flex",
  flexWrap: "wrap",
  alignItems: "flex-start",
  gap: "md",
});
const wide = css({ flex: "1 1 100%", minWidth: "0" });
const narrow = css({ width: "26rem", maxWidth: "100%" });
---

<div class={layout}>
  <List
    card={{ heading: "Failed jobs", subheading: 'trailingPlacement="below"' }}
    trailingPlacement={"below"}
    items={[
      {
        id: "deploy",
        title: "Deploy web-frontend",
        description: "Failed at step 4 of 6 · health check",
        value: "3m 12s",
      },
      {
        id: "migrate",
        title: "Migrate billing database",
        description: "Timed out waiting for a table lock",
        value: "30m 00s",
      },
      {
        id: "backup",
        title: "Nightly backup",
        description: "Snapshot upload stalled at 82%",
        value: "12m 40s",
      },
    ]}
  >
    <Fragment slot="deploy:trailing">
      <Button
        size="sm"
        emphasis="secondary"
        aria-label={`Retry ${"web-frontend deploy"}`}
      >
        Retry
      </Button>
      <Button
        size="sm"
        emphasis="ghost"
        aria-label={`Logs for ${"web-frontend deploy"}`}
      >
        Logs
      </Button>
    </Fragment>
    <Fragment slot="migrate:trailing">
      <Button
        size="sm"
        emphasis="secondary"
        aria-label={`Retry ${"billing migration"}`}
      >
        Retry
      </Button>
      <Button
        size="sm"
        emphasis="ghost"
        aria-label={`Logs for ${"billing migration"}`}
      >
        Logs
      </Button>
    </Fragment>
    <Fragment slot="backup:trailing">
      <Button
        size="sm"
        emphasis="secondary"
        aria-label={`Retry ${"nightly backup"}`}
      >
        Retry
      </Button>
      <Button
        size="sm"
        emphasis="ghost"
        aria-label={`Logs for ${"nightly backup"}`}
      >
        Logs
      </Button>
    </Fragment>
  </List>
  <div class={autoRow}>
    <div class={wide}>
      <List
        card={{ heading: "Failed jobs", subheading: '"auto" · wide list' }}
        trailingPlacement={"auto"}
        items={[
          {
            id: "deploy",
            title: "Deploy web-frontend",
            description: "Failed at step 4 of 6 · health check",
            value: "3m 12s",
          },
          {
            id: "migrate",
            title: "Migrate billing database",
            description: "Timed out waiting for a table lock",
            value: "30m 00s",
          },
          {
            id: "backup",
            title: "Nightly backup",
            description: "Snapshot upload stalled at 82%",
            value: "12m 40s",
          },
        ]}
      >
        <Fragment slot="deploy:trailing">
          <Button
            size="sm"
            emphasis="secondary"
            aria-label={`Retry ${"web-frontend deploy"}`}
          >
            Retry
          </Button>
          <Button
            size="sm"
            emphasis="ghost"
            aria-label={`Logs for ${"web-frontend deploy"}`}
          >
            Logs
          </Button>
        </Fragment>
        <Fragment slot="migrate:trailing">
          <Button
            size="sm"
            emphasis="secondary"
            aria-label={`Retry ${"billing migration"}`}
          >
            Retry
          </Button>
          <Button
            size="sm"
            emphasis="ghost"
            aria-label={`Logs for ${"billing migration"}`}
          >
            Logs
          </Button>
        </Fragment>
        <Fragment slot="backup:trailing">
          <Button
            size="sm"
            emphasis="secondary"
            aria-label={`Retry ${"nightly backup"}`}
          >
            Retry
          </Button>
          <Button
            size="sm"
            emphasis="ghost"
            aria-label={`Logs for ${"nightly backup"}`}
          >
            Logs
          </Button>
        </Fragment>
      </List>
    </div>
    <div class={narrow}>
      <List
        card={{ heading: "Failed jobs", subheading: '"auto" · 26rem wide' }}
        trailingPlacement={"auto"}
        items={[
          {
            id: "deploy",
            title: "Deploy web-frontend",
            description: "Failed at step 4 of 6 · health check",
            value: "3m 12s",
          },
          {
            id: "migrate",
            title: "Migrate billing database",
            description: "Timed out waiting for a table lock",
            value: "30m 00s",
          },
          {
            id: "backup",
            title: "Nightly backup",
            description: "Snapshot upload stalled at 82%",
            value: "12m 40s",
          },
        ]}
      >
        <Fragment slot="deploy:trailing">
          <Button
            size="sm"
            emphasis="secondary"
            aria-label={`Retry ${"web-frontend deploy"}`}
          >
            Retry
          </Button>
          <Button
            size="sm"
            emphasis="ghost"
            aria-label={`Logs for ${"web-frontend deploy"}`}
          >
            Logs
          </Button>
        </Fragment>
        <Fragment slot="migrate:trailing">
          <Button
            size="sm"
            emphasis="secondary"
            aria-label={`Retry ${"billing migration"}`}
          >
            Retry
          </Button>
          <Button
            size="sm"
            emphasis="ghost"
            aria-label={`Logs for ${"billing migration"}`}
          >
            Logs
          </Button>
        </Fragment>
        <Fragment slot="backup:trailing">
          <Button
            size="sm"
            emphasis="secondary"
            aria-label={`Retry ${"nightly backup"}`}
          >
            Retry
          </Button>
          <Button
            size="sm"
            emphasis="ghost"
            aria-label={`Logs for ${"nightly backup"}`}
          >
            Logs
          </Button>
        </Fragment>
      </List>
    </div>
  </div>
</div>

Fonts

valueFont sets the value in "tabular" sans figures (the default) or the "mono" family — commit shas, ids. descriptionFont does the same for the description (stacked) / note (pairs) line: "default", "tabular" or "mono". Any item overrides either with its own valueFont / descriptionFont.

Recent commits

main · api-gateway

  • Retry idempotent requests once

    Noor Haddad · 14 min ago

    4be19c0
  • Cap upstream timeouts at 8 s

    Lukas Brandt · 2 h ago

    e07a5d2
  • Log the resolved route id

    trace 7f3a-11c2-9b04

    91cf3e8
---
// GENERATED from packages/demos/list/fonts.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
---

<List
  valueFont="mono"
  card={{ heading: "Recent commits", subheading: "main · api-gateway" }}
  items={[
    {
      id: "c1",
      title: "Retry idempotent requests once",
      description: "Noor Haddad · 14 min ago",
      value: "4be19c0",
    },
    {
      id: "c2",
      title: "Cap upstream timeouts at 8 s",
      description: "Lukas Brandt · 2 h ago",
      value: "e07a5d2",
    },
    {
      id: "c3",
      title: "Log the resolved route id",
      description: "trace 7f3a-11c2-9b04",
      descriptionFont: "mono",
      value: "91cf3e8",
    },
  ]}
/>

An item with href becomes a link row, one with onclick a button row (Astro renders no client script, so it supports href only). The title itself becomes the <a> / <button> and a pseudo-element overlay stretches it over the whole row — one click target, one tab stop, and a properly named link. Trailing controls are raised above the overlay, so a Button in the trailing cell stays its own valid, clickable control. Rows tint on hover, and the keyboard focus ring is a real outline drawn inside the row, so neither the card’s corners nor a scroll container clip it.

Workspace settings

  • Name, avatar and time zone

  • Invoices and payment method

  • Tokens for the REST API

    3 active
  • Used to verify webhooks

Last action: none yet
---
// GENERATED from packages/demos/list/links.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
import Badge from "@pindoba/astro-badge";
import Stamp from "@pindoba/astro-stamp";
import { ChevronRight } from "@lucide/astro";

const lastAction = "none yet";
---

<List
  card={{
    heading: "Workspace settings",
    footer: { leading: `Last action: ${lastAction}` },
  }}
  items={[
    {
      id: "profile",
      title: "Profile",
      description: "Name, avatar and time zone",
      href: "#profile",
    },
    {
      id: "billing",
      title: "Billing",
      description: "Invoices and payment method",
      href: "#billing",
    },
    {
      id: "api",
      title: "API keys",
      description: "Tokens for the REST API",
      href: "#api-keys",
    },
    {
      id: "signing",
      title: "Signing key",
      description: "Used to verify webhooks",
      action: "button",
    },
  ]}
  data-ir-root="list/links"
  data-ir="1"
>
  <Stamp slot="profile:trailing" emphasis="ghost">
    <ChevronRight />
  </Stamp>
  <Stamp slot="billing:trailing" emphasis="ghost">
    <ChevronRight />
  </Stamp>
  <Badge slot="api:trailing" size="sm">
    3 active
  </Badge>
</List>

<script>
  import { hydrate } from "./_client/links.ts";

  document
    .querySelectorAll('[data-ir-root="list/links"]')
    .forEach((root) => hydrate([root]));
</script>

Rows that share a title (“Invoice”, “Details”) need distinct accessible names. Give each row an actionLabel, which becomes the aria-label of its <a> or <button>. It takes precedence over a list-wide passThrough.action.props["aria-label"] for that row only, and the row’s own passThrough.action.props take precedence over it.

<List
  items={invoices.map((invoice) => ({
    id: invoice.id,
    title: "Invoice",
    value: invoice.total,
    href: `/invoices/${invoice.id}`,
    actionLabel: `Open invoice ${invoice.number}`,
  }))}
/>

Per-item data, class and style

Items only forward DOM-shaped keys to the row element (the <li>, or the pair <div>): data-*, aria-*, on* event handlers, role, lang, dir, hidden and tabindex. List drops any other key it doesn’t read. To pass values to a custom row renderer, put them in data. It’s never rendered, and the renderer reads it back as row.data (React renderRow, the Svelte row snippet, the Vue #row slot). ListItemInput<TData> and ListRowApi<TData> (and connectList<TSlot, TData>) type it. The component callbacks receive it as unknown, so narrow it before use.

Row renderer naming

The custom-row escape hatch is named for each framework’s idiom, and the two names are deliberate:

  • Svelte — the row snippet.
  • Vue — the row scoped slot (#row).
  • React and Solid — the renderRow render prop.
  • Astro — the per-row <id> named slot (see Rich rows).

All of them receive the same resolved row, with the item’s data on row.data.

<List
  items={[{ id: "a", custom: true, data: { amount: 42 } }]}
  renderRow={(row) => (isInvoice(row.data) ? <Amount {...row.data} /> : null)}
/>

A row’s class (className in React) merges with the recipe class and never replaces it. Its style is kept on the row element as a string in any framework, or as an object in React, Vue or Astro. To give one row a Panda style, use the row’s own passThrough. It takes the same row-level slots as the list’s passThrough (item, action, leading, row, title, description, value, trailing, and for pairs pair, term, detail, note, pairTrailing) and applies after it. Its style goes through css() at runtime, so build it with css.raw(), which lets Panda’s static extraction see it and emit the CSS.

import { css } from "@pindoba/styled-system/css";

const items = [
  { id: "a", title: "Failed job", class: "row-failed" },
  {
    id: "b",
    title: "Pinned",
    passThrough: { item: { style: css.raw({ bg: "surface.peak" }) } },
  },
];

The demo gives four link rows the same title and a distinct actionLabel each, highlights the overdue row through its own passThrough.item.style, and renders a summary row from its data. Astro has no render callback, so its custom row is a static <id> slot.

Invoices

Acme Corp · 2026

  • INV-2041 · Paid Sep 2

    $1,240.00
  • INV-2042 · Paid Sep 16

    $2,180.00
  • INV-2043 · Overdue by 12 days

    $960.00
  • INV-2044 · Due Oct 14

    $2,435.00
  • 4 invoices · 1 overdue$6,815.00
---
// GENERATED from packages/demos/list/per-item.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
import { css } from "@pindoba/styled-system/css";
import type { ListItem } from "@pindoba/core-list";

interface InvoiceSummary {
  count: number;
  overdue: number;
  total: string;
}

const isSummary = (data: unknown): data is InvoiceSummary => {
  return typeof data === "object" && data !== null && "total" in data;
};
const summaryOf = (data: unknown): InvoiceSummary | undefined =>
  isSummary(data) ? data : undefined;
const items = [
  {
    id: "inv-2041",
    title: "Invoice",
    description: "INV-2041 · Paid Sep 2",
    value: "$1,240.00",
    href: "#invoice-2041",
    actionLabel: "Open invoice INV-2041",
  },
  {
    id: "inv-2042",
    title: "Invoice",
    description: "INV-2042 · Paid Sep 16",
    value: "$2,180.00",
    href: "#invoice-2042",
    actionLabel: "Open invoice INV-2042",
  },
  {
    id: "inv-2043",
    title: "Invoice",
    description: "INV-2043 · Overdue by 12 days",
    value: "$960.00",
    href: "#invoice-2043",
    actionLabel: "Open invoice INV-2043, overdue",
    passThrough: {
      item: {
        style: css.raw({
          boxShadow: "inset 3px 0 0 token(colors.warning.border.accent)",
        }),
      },
    },
  },
  {
    id: "inv-2044",
    title: "Invoice",
    description: "INV-2044 · Due Oct 14",
    value: "$2,435.00",
    href: "#invoice-2044",
    actionLabel: "Open invoice INV-2044",
  },
  {
    id: "summary",
    custom: true,
    data: { count: 4, overdue: 1, total: "$6,815.00" },
  },
] as ListItem<never, InvoiceSummary>[];
const summary = css({
  display: "flex",
  alignItems: "baseline",
  justifyContent: "space-between",
  gap: "md",
  fontSize: "sm",
});
const muted = css({ color: "neutral.text.muted" });
const total = css({
  fontWeight: "semibold",
  fontVariantNumeric: "tabular-nums",
});
---

<List
  card={{ heading: "Invoices", subheading: "Acme Corp · 2026" }}
  items={items}
>
  {items
    .filter((row) => row.custom)
    .map((row) => (
      <Fragment slot={row.id}>
        {isSummary(row.data) && (
          <div class={summary}>
            <span class={muted}>
              {summaryOf(row.data)?.count} invoices ·{" "}
              {summaryOf(row.data)?.overdue} overdue
            </span>
            <strong class={total}>{summaryOf(row.data)?.total}</strong>
          </div>
        )}
      </Fragment>
    ))}
</List>

Client-side routing

Link rows are plain <a href> links, so by default a click loads the page. To route through your app’s router instead, List takes the same hooks as Navigation: the router renders or intercepts the row links, and everything else — the stretched click target, the focus ring, trailing controls — keeps working. An item’s onclick still turns only href-less rows into buttons; for link rows, route with the hooks below.

Plain clicks only. Every framework hook below only sees clicks the browser would follow in the current tab. Cmd/Ctrl-click, Shift-click, Alt-click, middle clicks, target="_blank", download, rel="external", cross-origin links and clicks another handler already preventDefault()ed are left to the browser — so “open in new tab” keeps working. Keyboard activation (Enter) arrives as a plain click and routes the same way. The rule is isPlainNavigationClick, shared with Navigation and exported from @pindoba/utils for your own router links.

The demo cancels every routed click and shows the last href instead of loading it: onNavigate in Svelte and React (React also renders the titles through a local linkComponent), @navigate in Vue and a pindoba:navigate listener in Astro.

Project settings

Last navigation: none yet

---
// GENERATED from packages/demos/list/routing.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

const status = css({
  margin: "0",
  fontSize: "sm",
  color: "neutral.text.muted",
});

const lastNavigation = "none yet";
---

<div class={stack({ gap: "sm", width: "100%" })} data-ir-root="list/routing">
  <List
    card={{ heading: "Project settings" }}
    items={[
      {
        id: "general",
        title: "General",
        description: "Name, slug and default branch",
        href: "?view=general",
      },
      {
        id: "members",
        title: "Members",
        description: "Who can deploy and who can review",
        href: "?view=members",
      },
      {
        id: "integrations",
        title: "Integrations",
        description: "GitHub, Slack and webhooks",
        href: "?view=integrations",
      },
    ]}
    data-ir="1"
  />
  <p class={status} aria-live="polite">
    Last navigation: <!--ir:2-->
    {lastNavigation}
    <!--/ir-->
  </p>
</div>

<script>
  import { hydrate } from "./_client/routing.ts";

  document
    .querySelectorAll('[data-ir-root="list/routing"]')
    .forEach((root) => hydrate([root]));
</script>

React: TanStack Router and Next.js

linkComponent renders every link row’s title through your router’s <Link>. It receives the anchor’s full bag — href, className, target, rel, data-list-action — and the title as its children. Button and plain rows are unaffected.

Next.js’s Link takes href, so it plugs in as-is:

import Link from "next/link";
import { List } from "@pindoba/react-list";

<List items={items} linkComponent={Link} />;

TanStack Router’s Link takes to, so map it in a small adapter:

import { forwardRef } from "react";
import { Link } from "@tanstack/react-router";
import { List, type ListLinkProps } from "@pindoba/react-list";

const RouterLink = forwardRef<HTMLAnchorElement, ListLinkProps>(
  function RouterLink({ href, ...props }, ref) {
    return <Link to={href} {...props} ref={ref} />;
  },
);

<List items={items} linkComponent={RouterLink} />;

If you’d rather keep plain anchors, onNavigate works in React too:

const navigate = useNavigate();

<List
  items={items}
  onNavigate={(href, event) => {
    event.preventDefault();
    navigate({ to: href });
  }}
/>;

Svelte and Vue: onNavigate

onNavigate(href, event, itemId) is called for each plain click on one of the list’s link rows; itemId is the row’s item id. Call event.preventDefault() and hand href to the router; return without preventing to let the browser follow the link.

<List
  {items}
  onNavigate={(href, event) => {
    event.preventDefault();
    goto(href);
  }}
/>

SvelteKit needs no hook at all — it already intercepts plain <a> clicks. Svelte has no linkComponent.

In Vue it’s the navigate event. Vue also takes a linkComponent: the component receives the anchor bag plus to: href and renders the title as its default slot, so Vue Router’s RouterLink (or Nuxt’s NuxtLink) works as-is:

<script setup lang="ts">
import { RouterLink } from "vue-router";
</script>

<template>
  <List :items="items" :linkComponent="RouterLink" />
</template>

Astro: the pindoba:navigate event

A server-rendered router needs nothing. For a client-side router, the list dispatches a cancelable, bubbling pindoba:navigate event on the <ul> for each plain link-row click — the same event Navigation dispatches, so one listener on document can route both. event.detail carries { href, itemId, originalEvent }, and cancelling the event cancels the click:

document.addEventListener("pindoba:navigate", (event) => {
  event.preventDefault();
  router.navigate(event.detail.href);
});

Pairs

layout="pairs" renders a <dl>: each item is { id, term, value, description?, trailing? } and becomes a <div> with a muted <dt> label, the value <dd> (tabular figures, flush right) and an optional note <dd> under the label. Pairs share the stacked rows’ padding and type tokens.

TLS certificate

api.lumen.dev

Valid
Issuer
Northwind Trust R3
Valid from
14 Jul 2026
Expires
12 Jan 2027
Renews automatically 30 days before
Key
ECDSA P-256
Serial
04:A1:9C:3E:77
---
// GENERATED from packages/demos/list/pairs.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
import Badge from "@pindoba/astro-badge";
---

<List
  layout="pairs"
  card={{
    heading: "TLS certificate",
    subheading: "api.lumen.dev",
  }}
  items={[
    { id: "issuer", term: "Issuer", value: "Northwind Trust R3" },
    { id: "from", term: "Valid from", value: "14 Jul 2026" },
    {
      id: "until",
      term: "Expires",
      value: "12 Jan 2027",
      description: "Renews automatically 30 days before",
    },
    { id: "key", term: "Key", value: "ECDSA P-256" },
    { id: "serial", term: "Serial", value: "04:A1:9C:3E:77" },
  ]}
>
  <Badge slot="card-trailing" size="sm" feedback="success">
    Valid
  </Badge>
</List>

Rich terms and a trailing cell

A pair’s term takes rich content (an <abbr>, an icon) and trailing adds a trailing <dd> at the pair’s end — a copy action, a status Badge. valueFont / descriptionFont apply to pairs too.

Deploy key

ci@build-farm

Type
ed25519
Active
Fingerprint
SHA256:q3Vt…9LwE
Added
2026-08-02
---
// GENERATED from packages/demos/list/pairs-trailing.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
import Badge from "@pindoba/astro-badge";
---

<List
  layout="pairs"
  valueFont="mono"
  card={{ heading: "Deploy key", subheading: "ci@build-farm" }}
  items={[
    { id: "type", term: "Type", value: "ed25519" },
    {
      id: "fp",
      value: "SHA256:q3Vt…9LwE",
    },
    {
      id: "added",
      term: "Added",
      value: "2026-08-02",
      valueFont: "tabular",
    },
  ]}
  data-ir-root="list/pairs-trailing"
  data-ir="1"
>
  <Badge slot="type:trailing" size="sm" feedback="success">
    Active
  </Badge>
  <abbr slot="fp:term" title="SHA-256 fingerprint">
    Fingerprint
  </abbr>
</List>

<script>
  import { hydrate } from "./_client/pairs-trailing.ts";

  document
    .querySelectorAll('[data-ir-root="list/pairs-trailing"]')
    .forEach((root) => hydrate([root]));
</script>

Pairs grid

orientation="vertical" puts the label above the value, and columns (1–4) lays the pairs out in a grid. With divided, the hairlines separate grid rows.

Deploy #1842

Promoted to production

Environment
production
Region
eu-west-2
Commit
4f2a9c1
Duration
2m 14s
Triggered by
m.okafor
Replicas
6 / 6
---
// GENERATED from packages/demos/list/pairs-grid.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
---

<List
  layout="pairs"
  orientation="vertical"
  columns={3}
  card={{ heading: "Deploy #1842", subheading: "Promoted to production" }}
  items={[
    { id: "env", term: "Environment", value: "production" },
    { id: "region", term: "Region", value: "eu-west-2" },
    { id: "commit", term: "Commit", value: "4f2a9c1" },
    { id: "duration", term: "Duration", value: "2m 14s" },
    { id: "author", term: "Triggered by", value: "m.okafor" },
    { id: "replicas", term: "Replicas", value: "6 / 6" },
  ]}
/>

Sizes

size (2xs–lg) scales the row padding, the title / description / value type and the leading addon box together. It is also the Card’s size.

Size 2xs

  • CPU

    8 vCPU

    37%
  • Memory

    32 GB

    61%
  • Disk

    500 GB NVMe

    18%

Size xs

  • CPU

    8 vCPU

    37%
  • Memory

    32 GB

    61%
  • Disk

    500 GB NVMe

    18%

Size sm

  • CPU

    8 vCPU

    37%
  • Memory

    32 GB

    61%
  • Disk

    500 GB NVMe

    18%

Size md

  • CPU

    8 vCPU

    37%
  • Memory

    32 GB

    61%
  • Disk

    500 GB NVMe

    18%

Size lg

  • CPU

    8 vCPU

    37%
  • Memory

    32 GB

    61%
  • Disk

    500 GB NVMe

    18%
---
// GENERATED from packages/demos/list/sizes.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
import { grid } from "@pindoba/styled-system/patterns";

const sizes = ["2xs", "xs", "sm", "md", "lg"] as const;
const items = [
  { id: "cpu", title: "CPU", description: "8 vCPU", value: "37%" },
  { id: "mem", title: "Memory", description: "32 GB", value: "61%" },
  { id: "disk", title: "Disk", description: "500 GB NVMe", value: "18%" },
];
---

<div
  class={grid({
    columns: { base: 1, md: 2 },
    gap: "md",
    alignItems: "start",
  })}
>
  {sizes.map((size) => (
    <List size={size} card={{ heading: `Size ${size}` }} items={items} />
  ))}
</div>

Rhythm

rhythm puts the rows on the baseline raster: rows pad nothing and measure whole lines (a quantised title / description, floored at one line for 2xs–sm, two for md / lg), and divided hairlines take no block space, so the card lands on whole lines whatever the row count. In card mode the List’s Card defaults to rhythm too (card.rhythm wins). A List inside any [data-rhythm] scope — a rhythm Card or Grid — is on the raster without the prop.

Release 4.2

size sm

  • Code freeze

    Branch cut from main

    Mon
  • QA sign-off

    Regression suite green

    Wed
  • Release notes
    Thu
  • Ship to stores

    Staged rollout, 10%

    Fri

Release 4.2

size md

  • Code freeze

    Branch cut from main

    Mon
  • QA sign-off

    Regression suite green

    Wed
  • Release notes
    Thu
  • Ship to stores

    Staged rollout, 10%

    Fri
---
// GENERATED from packages/demos/list/rhythm.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
import { css } from "@pindoba/styled-system/css";

const steps = [
  {
    id: "freeze",
    title: "Code freeze",
    description: "Branch cut from main",
    value: "Mon",
  },
  {
    id: "qa",
    title: "QA sign-off",
    description: "Regression suite green",
    value: "Wed",
  },
  { id: "notes", title: "Release notes", value: "Thu" },
  {
    id: "ship",
    title: "Ship to stores",
    description: "Staged rollout, 10%",
    value: "Fri",
  },
];
---

<div
  class={css({
    display: "grid",
    gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 18rem), 1fr))",
    gap: "md",
    width: "100%",
  })}
>
  <List
    rhythm
    size="sm"
    card={{ heading: "Release 4.2", subheading: "size sm" }}
    items={steps}
  />
  <List
    rhythm
    card={{ heading: "Release 4.2", subheading: "size md" }}
    items={steps}
  />
</div>

Without a card

card={false} renders the bare <ul> / <dl> — for a List inside a surface that already frames it. A List that is the only child of another Card’s content dissolves into it on its own (data-card-flushable), so this is only needed elsewhere. Give a bare list an aria-label.

  • Release notes drafted
    Done
  • QA sign-off
    Done
  • Feature flags reviewed
    Pending
---
// GENERATED from packages/demos/list/bare.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
---

<List
  card={false}
  divided={false}
  size="sm"
  aria-label="Release checklist"
  items={[
    { id: "notes", title: "Release notes drafted", value: "Done" },
    { id: "qa", title: "QA sign-off", value: "Done" },
    { id: "flags", title: "Feature flags reviewed", value: "Pending" },
  ]}
/>

A bare List in a Card

A bare List placed straight in a hand-written Card’s content adopts the host’s inset. In a padded content region (the Card default) its rows drop their own inline padding, so the row text lands on the content’s text edge — no double indent — on flush and inset cards alike. In an unpadded one (content.padding: "none") the rows keep their padding and publish it to the host card (--card-content-row-inset), so a flush header lines up with the row text.

Build agents

Flush card

Online
  • runner-a1

    linux-x64 · 16 vCPU

    3 jobs
  • runner-a2

    linux-x64 · 16 vCPU

    idle
  • runner-m1

    macos-arm64 · 12 cores

    1 job

Build agents

Inset card

Online
  • runner-a1

    linux-x64 · 16 vCPU

    3 jobs
  • runner-a2

    linux-x64 · 16 vCPU

    idle
  • runner-m1

    macos-arm64 · 12 cores

    1 job
---
// GENERATED from packages/demos/list/in-card.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
import Card from "@pindoba/astro-card";
import Badge from "@pindoba/astro-badge";
import { css } from "@pindoba/styled-system/css";

const agents = [
  {
    id: "a1",
    title: "runner-a1",
    description: "linux-x64 · 16 vCPU",
    value: "3 jobs",
  },
  {
    id: "a2",
    title: "runner-a2",
    description: "linux-x64 · 16 vCPU",
    value: "idle",
  },
  {
    id: "m1",
    title: "runner-m1",
    description: "macos-arm64 · 12 cores",
    value: "1 job",
  },
];
---

<div
  class={css({
    display: "grid",
    gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 18rem), 1fr))",
    gap: "md",
    width: "100%",
  })}
>
  <Card
    header={{
      heading: "Build agents",
      subheading: "Flush card",
    }}
  >
    <Badge slot="header-trailing" feedback="success" size="sm">
      Online
    </Badge>
    <List card={false} aria-label="Build agents" items={agents} />
  </Card>
  <Card
    background="surface.base"
    contentBackground="surface.peak"
    header={{
      heading: "Build agents",
      subheading: "Inset card",
    }}
  >
    <Badge slot="header-trailing" feedback="success" size="sm">
      Online
    </Badge>
    <List card={false} aria-label="Build agents" items={agents} />
  </Card>
</div>

That automatic rule only covers a list that is the direct child of the content region. Nested one level down (a <section> holding a group heading and the list) or outside any card (under a page header), the rows keep their inset and the row text sits indented past the text around it. Pass flush to drop the row inset wherever the list sits:

<Card header={{ heading: "Settings" }}>
  <section>
    <h4>Notifications</h4>
    <List {items} card={false} flush aria-label="Notifications" />
  </section>
</Card>

Hairlines and hover tints then span the text column. On a List in card mode the card header’s inset follows, so the header stays lined up with the rows.

Notifications

Without flush

Email

  • Mentions

    When someone @-mentions you in a thread

    Instant
  • Weekly digest

    A summary of workspace activity

    Mondays

Push

  • Deploys

    When a deploy you started finishes

    On
  • Billing alerts

    Failed payments and plan renewals

    Off

Notifications

With flush

Email

  • Mentions

    When someone @-mentions you in a thread

    Instant
  • Weekly digest

    A summary of workspace activity

    Mondays

Push

  • Deploys

    When a deploy you started finishes

    On
  • Billing alerts

    Failed payments and plan renewals

    Off
---
// GENERATED from packages/demos/list/flush.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
import Card from "@pindoba/astro-card";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

const email = [
  {
    id: "mentions",
    title: "Mentions",
    description: "When someone @-mentions you in a thread",
    value: "Instant",
  },
  {
    id: "digest",
    title: "Weekly digest",
    description: "A summary of workspace activity",
    value: "Mondays",
  },
];
const push = [
  {
    id: "deploys",
    title: "Deploys",
    description: "When a deploy you started finishes",
    value: "On",
  },
  {
    id: "billing",
    title: "Billing alerts",
    description: "Failed payments and plan renewals",
    value: "Off",
  },
];
const grid = css({
  display: "grid",
  gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 18rem), 1fr))",
  gap: "md",
  width: "100%",
});
const sections = stack({ gap: "md" });
const section = stack({ gap: "2xs" });
const groupHeading = css({
  margin: "0",
  fontSize: "xs",
  fontWeight: "semibold",
  color: "neutral.text.muted",
});
---

<div class={grid}>
  <Card header={{ heading: "Notifications", subheading: "Without flush" }}>
    <div class={sections}>
      <section class={section}>
        <h4 class={groupHeading}>Email</h4>
        <List card={false} aria-label="Email notifications" items={email} />
      </section>
      <section class={section}>
        <h4 class={groupHeading}>Push</h4>
        <List card={false} aria-label="Push notifications" items={push} />
      </section>
    </div>
  </Card>
  <Card header={{ heading: "Notifications", subheading: "With flush" }}>
    <div class={sections}>
      <section class={section}>
        <h4 class={groupHeading}>Email</h4>
        <List
          card={false}
          flush
          aria-label="Email notifications"
          items={email}
        />
      </section>
      <section class={section}>
        <h4 class={groupHeading}>Push</h4>
        <List card={false} flush aria-label="Push notifications" items={push} />
      </section>
    </div>
  </Card>
</div>

A bare List’s rows take their text colour from the surface they sit on, so rows inside a filled surface — an emphasis="primary" Alert or Card — read in its contrast text. A row with its own feedback / emphasis / muted state keeps its own palette.

Alignment with the card header

The rows pad themselves (--list-row-pad-x) and the card’s content region is edge to edge (content.padding: "none"). The list’s Card root republishes the row padding as --card-content-row-inset, and what the Card does with it depends on the mode:

  • Flush (the default): the header and footer pad by the row inset, so the card heading starts exactly where the row text (or the leading addon) does.
  • Inset (card={{ background: …, contentBackground: … }}): the list sits on its own panel and a transparent header or footer sits on the panel’s outer edge — the heading starts on the panel’s left edge and trailing actions end on its right edge, with the shell gutter as the only spacing. The rows keep their own padding, so the row text is intentionally indented past the heading. A header or footer with its own background becomes a tile that pads by the row inset, so its text does line up with the rows.

Storage buckets

Flush (the default)

  • media-uploads

    eu-west · versioned

    412 GB
  • access-logs

    eu-west · 90-day lifecycle

    1.8 TB
  • report-exports

    us-east · private

    96 GB
3 buckets · 2.3 TB

Storage buckets

Inset panel

  • media-uploads

    eu-west · versioned

    412 GB
  • access-logs

    eu-west · 90-day lifecycle

    1.8 TB
  • report-exports

    us-east · private

    96 GB
3 buckets · 2.3 TB
---
// GENERATED from packages/demos/list/inset.demo.tsx — edit the source, not this file.
import List from "@pindoba/astro-list";
import { css } from "@pindoba/styled-system/css";

const buckets = [
  {
    id: "media",
    title: "media-uploads",
    description: "eu-west · versioned",
    value: "412 GB",
  },
  {
    id: "logs",
    title: "access-logs",
    description: "eu-west · 90-day lifecycle",
    value: "1.8 TB",
  },
  {
    id: "exports",
    title: "report-exports",
    description: "us-east · private",
    value: "96 GB",
  },
];
---

<div
  class={css({
    display: "grid",
    gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 18rem), 1fr))",
    gap: "md",
    width: "100%",
  })}
>
  <List
    card={{
      heading: "Storage buckets",
      subheading: "Flush (the default)",
      footer: { leading: "3 buckets · 2.3 TB" },
    }}
    items={buckets}
  />
  <List
    card={{
      heading: "Storage buckets",
      subheading: "Inset panel",
      background: "surface.base",
      contentBackground: "surface.peak",
      footer: { leading: "3 buckets · 2.3 TB" },
    }}
    items={buckets}
  />
</div>
<List
  {items}
  card={{
    heading: "Nightly jobs",
    background: "surface.base",
    contentBackground: "surface.peak",
  }}
/>

Custom rows (custom: true, rendered through the row snippet / renderRow / #row slot / <id> Astro slot) keep the same <li> padding.

Accessibility

A stacked list is a <ul role="list"> (the explicit role keeps list semantics in Safari despite list-style: none). When the card has a heading, the <ul> is aria-labelledby it, unless you pass aria-label / aria-labelledby. Row titles are not headings. Link and button rows expose exactly one interactive element per row — the title — plus any trailing controls. The pairs layout is a native description list.

  • Components
  • Blocks