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.
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).
---
// 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 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).
---
// 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>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:
<id>:title, <id>:description, <id>:leading, <id>:value, <id>:trailing, and <id> for a whole custom row (custom: true);<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.
---
// 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>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.
---
// 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>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.
---
// 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>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" />
---
// 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>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.
---
// 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.
---
// 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}`,
}))}
/>
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.
The custom-row escape hatch is named for each framework’s idiom, and the two names are deliberate:
row snippet.row scoped slot (#row).renderRow render prop.<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.
---
// 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>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.
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>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 });
}}
/>;
onNavigateonNavigate(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>
pindoba:navigate eventA 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);
});
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.
---
// 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>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.
---
// 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>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.
---
// 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" },
]}
/>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.
---
// 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 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.
---
// 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>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.
---
// 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 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.
---
// 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.
---
// 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.
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:
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.---
// 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.
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.