component

Grid List

A list of things the user selects, acts on and opens, such as orders, messages, files or tasks. Its cells line up like a Table, but each row’s content is your own markup: a two-line cell, an avatar, chips, actions that appear on hover.

GridList declares one column template, and every row is a CSS subgrid of it, so the cells line up across rows and across groups. You render each row through a row render prop (a snippet in Svelte), and the list handles the row itself: selection, keyboard focus, the stretched link that opens it, groups and drag and drop.

GridList ships for Svelte, React, Vue, Solid and Astro (@pindoba/{svelte,react,vue,solid,astro}-grid-list). Every framework runs the same logic from @pindoba/core-grid-list, and drag and drop lives in @pindoba/core-drag. See Framework notes for how each one takes its rows. The Rows lab shows it in full scenarios: a store’s orders, an inbox, a drag-and-drop board, a gallery, a photo library and a settings list.

When to use it

  • List shows static key/value rows and Banner-style rows. It has no selection and no keyboard model.
  • ListBox picks options. Its rows are choices, not things you open.
  • Table shows data defined by columns (TanStack, headers, sorting, editable cells). Use it for a spreadsheet-like task, even a grouped one.
  • GridList is the third case: a collection of entities whose rows you write yourself, with Table-like alignment, keyboard selection and an “open” action per row. It pairs with the Bulk Bar, which reads its selection through idSelection.

Default

columns is the wide template: an id and a CSS track per column, plus an optional header label (header draws the sticky header row). The row render prop receives the item and a context. Spread cell(id) on each cell to place it in its column, and spread primaryProps on the row’s main link. That link stretches over the whole row, so a click anywhere on the row opens it, while the controls inside the row stay real buttons.

---
// Hand-written: `packages/demos/grid-list/default.demo.tsx` skips Astro (see
// its `skip`) — Astro GridList takes its rows through one default-slot
// callback, not a `row` render prop.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { css } from "@pindoba/styled-system/css";

interface Doc {
  id: string;
  name: string;
  kind: string;
  owner: string;
  modified: string;
  size: string;
}

const docs: Doc[] = [
  {
    id: "d1",
    name: "Quarterly report",
    kind: "PDF document",
    owner: "Inês Duarte",
    modified: "Today",
    size: "2.4 MB",
  },
  {
    id: "d2",
    name: "Brand guidelines",
    kind: "Design file",
    owner: "Kofi Mensah",
    modified: "Yesterday",
    size: "18 MB",
  },
  {
    id: "d3",
    name: "Hiring plan",
    kind: "Spreadsheet",
    owner: "Yuna Choi",
    modified: "Oct 24",
    size: "320 KB",
  },
  {
    id: "d4",
    name: "Launch checklist",
    kind: "Document",
    owner: "Inês Duarte",
    modified: "Oct 19",
    size: "48 KB",
  },
];

const nameCls = css({ display: "grid", minWidth: "0", lineHeight: "1.25" });
const kindCls = css({
  color: "neutral.text.muted",
  textStyle: "caption",
  truncate: true,
});
const mutedCls = css({ color: "neutral.text.muted" });
const sizeCls = css({
  justifyContent: "flex-end",
  color: "neutral.text.muted",
  fontVariantNumeric: "tabular-nums",
  whiteSpace: "nowrap",
});
---

<GridList
  label="Documents"
  items={docs}
  getId={(doc: Doc) => doc.id}
  header
  columns={[
    { id: "name", track: "minmax(0, 1fr)", header: "Name" },
    { id: "owner", track: "auto", header: "Owner" },
    { id: "modified", track: "auto", header: "Modified" },
    { id: "size", track: "auto", header: "Size" },
  ]}
  narrow={{
    columns: "minmax(0, 1fr) auto",
    cells: {
      name: { column: "1", row: "1" },
      size: { column: "2", row: "1" },
      owner: { column: "1", row: "2" },
      modified: { column: "2", row: "2" },
    },
  }}
>
  {(ctx: GridListRender<Doc>) =>
    ctx.region === "row" ? (
      <Fragment>
        <span {...ctx.cell("name")}>
          <span class={nameCls}>
            <a {...ctx.primaryProps} href={`#${ctx.item.id}`}>
              {ctx.item.name}
            </a>
            <span class={kindCls}>{ctx.item.kind}</span>
          </span>
        </span>
        <span {...ctx.cell("owner", { class: mutedCls })}>
          {ctx.item.owner}
        </span>
        <span {...ctx.cell("modified", { class: mutedCls })}>
          {ctx.item.modified}
        </span>
        <span {...ctx.cell("size", { class: sizeCls })}>{ctx.item.size}</span>
      </Fragment>
    ) : null
  }
</GridList>

Pass extra classes as cell(id, { class }). A class= written after the spread replaces the cell’s own class and loses its placement. cell takes a few more options:

  • span: "date" stretches the cell from its own column through date.
  • line: 2 puts the cell on a second line of the row, for example a row of chips.
  • reveal: "rest" | "hover" stacks two cells in one area. The rest cell shows normally, and the hover cell replaces it on hover or focus. Both cells keep their size through visibility, so the row never re-flows as the pointer crosses it. Touch screens have no hover, so a hover cell never shows there: keep it for extra information, and put a row’s actions (archive, delete) in actions, which has a touch path.

Narrow fold

narrow is a second template the list switches to when it is narrower than 640px. The fold is a container query on the list itself, so the same rows fit a split reading pane as well as a phone. narrow.cells places each cell by column id, as { column, row } lines in the folded grid or { hidden: true }. A cell can take its own placement with narrowAs. Narrow the window in the demo above: each document folds into a two-line row, and the header hides. Leave narrow out and the list never folds.

Placement goes through CSS variables in each cell’s bag. The recipe has one rule per mode and never names your cells, so any set of columns folds.

Selection and keyboard

selectable gives each row a checkbox and makes the grid aria-multiselectable. The selection is value, a Set<string> of the selected ids: bind it (or pass value + onValueChange) to read or set it, or seed an uncontrolled list with defaultValue. The Shift-range anchor is the list’s own. The checkbox shows on hover, and on every row once anything is selected. In the plain and cards looks, adjacent selected rows join into one highlighted block. strong makes a row read bolder (unread, unpaid), and currentId marks the row whose detail is open (aria-current).

The rows are one tab stop, and focus moves between them with a roving tabindex. On a focused row:

KeyAction
↑ / ↓, K / JPrevious / next row
Home / EndFirst / last row
X / SpaceToggle the row (Shift: apply the anchor’s state up to it)
Shift + ↑ / ↓Grow the range from the anchor
⌘A / Ctrl+ASelect all (same scope as the header checkbox)
EscClear the selection
EnterClick the row’s primary link, then call onOpen(item)
→ / F2Move into the row’s controls (see below)

On a touch screen, a long press (500ms) on a row selects it, the way a phone starts a selection. The release doesn’t open the row’s link, and the long-press menu (link preview, text selection) stays away. A press on the row’s checkbox or another control in it is left to that control. Pair it with a visible way in, such as a “Select” button in the toolbar: a long press is a convention, not something the screen shows.

Focus a row and press Enter to open it.

---
// Hand-written: `packages/demos/grid-list/selection.demo.tsx` skips Astro
// (see its `skip`) — Astro GridList takes its rows through one default-slot
// callback, not a `row` render prop. The script below logs `grid-list:open`.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

interface Message {
  id: string;
  from: string;
  subject: string;
  preview: string;
  date: string;
  unread: boolean;
}

const messages: Message[] = [
  {
    id: "m1",
    from: "Inês Duarte",
    subject: "Venue shortlist",
    preview: "Three options left, all within budget.",
    date: "09:42",
    unread: true,
  },
  {
    id: "m2",
    from: "Kofi Mensah",
    subject: "Invoice #2041",
    preview: "Paid in full, receipt attached.",
    date: "08:15",
    unread: true,
  },
  {
    id: "m3",
    from: "Yuna Choi",
    subject: "Design review notes",
    preview: "Mostly spacing; the empty state needs a pass.",
    date: "Yesterday",
    unread: false,
  },
  {
    id: "m4",
    from: "Tomás Rivera",
    subject: "Offsite agenda",
    preview: "Draft for Thursday, comments welcome.",
    date: "Oct 26",
    unread: false,
  },
  {
    id: "m5",
    from: "Amara Okafor",
    subject: "Access request",
    preview: "Could you add me to the analytics workspace?",
    date: "Oct 25",
    unread: false,
  },
];
const subjects = Object.fromEntries(messages.map((m) => [m.id, m.subject]));

const fromCls = css({ truncate: true });
const textCls = css({ display: "flex", gap: "xs", minWidth: "0" });
const previewCls = css({
  color: "neutral.text.muted",
  fontWeight: "normal",
  truncate: true,
});
const dateCls = css({
  justifyContent: "flex-end",
  color: "neutral.text.muted",
  textStyle: "caption",
  whiteSpace: "nowrap",
});
const logCls = css({
  margin: "0",
  textStyle: "body.sm",
  color: "neutral.text.muted",
});
---

<div
  class={stack({ gap: "sm", width: "100%" })}
  data-demo-grid-list-inbox
  data-subjects={JSON.stringify(subjects)}
>
  <GridList
    label="Inbox"
    items={messages}
    getId={(message: Message) => message.id}
    selectable
    selectAll="footer"
    strong={(message: Message) => message.unread}
    columns={[
      { id: "from", track: "minmax(10rem, 16rem)" },
      { id: "text", track: "minmax(0, 1fr)" },
      { id: "date", track: "auto" },
    ]}
  >
    {(ctx: GridListRender<Message>) =>
      ctx.region === "row" ? (
        <Fragment>
          <span {...ctx.cell("from", { class: fromCls })}>{ctx.item.from}</span>
          <span {...ctx.cell("text")}>
            <span class={textCls}>
              <a {...ctx.primaryProps} href={`#${ctx.item.id}`}>
                {ctx.item.subject}
              </a>
              <span class={previewCls}>{ctx.item.preview}</span>
            </span>
          </span>
          <span {...ctx.cell("date", { class: dateCls })}>{ctx.item.date}</span>
        </Fragment>
      ) : null
    }
  </GridList>
  <p class={logCls} aria-live="polite" data-demo-log>
    Focus a row and press Enter to open it.
  </p>
</div>

<script>
  import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";

  for (const scope of document.querySelectorAll<HTMLElement>(
    "[data-demo-grid-list-inbox]",
  )) {
    const subjects = new Map<string, string>(
      Object.entries(JSON.parse(scope.dataset.subjects ?? "{}")),
    );
    const log = scope.querySelector("[data-demo-log]");
    const opened = (id: string): void => {
      if (log) log.textContent = `Opened “${subjects.get(id) ?? id}”`;
    };
    // The primary link is a demo anchor: open in place instead.
    scope.addEventListener("click", (event) => {
      const link =
        event.target instanceof Element
          ? event.target.closest<HTMLAnchorElement>("a[href^='#']")
          : null;
      if (!link) return;
      event.preventDefault();
      opened(link.hash.slice(1));
    });
    scope.addEventListener(GRID_LIST_EVENTS.open, (event) => {
      if (event instanceof CustomEvent && typeof event.detail?.id === "string")
        opened(event.detail.id);
    });
  }
</script>

Disabled rows

disabledIds lists the rows the user can’t select or deselect. A disabled row’s checkbox is disabled and the row is aria-disabled. Clicks, X / Space, Shift ranges, ⌘A, select-all and Esc all leave it as value has it, so a row that is already in value stays checked: the repositories below that are already added can’t be unchecked. Select-all counts only the rows it can change. A disabled row still takes the focus, opens with Enter and keeps its own controls.

Pick repositories to add; added and archived ones are locked.

---
// Hand-written: `packages/demos/grid-list/disabled.demo.tsx` skips Astro
// (see its `skip`) — Astro GridList takes its rows through one default-slot
// callback, not a `row` render prop. The script below counts the picks from
// `grid-list:valuechange`.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

interface Repository {
  id: string;
  name: string;
  path: string;
  status: string;
}

const repositories: Repository[] = [
  { id: "web", name: "web", path: "~/code/web", status: "Added" },
  { id: "api", name: "api", path: "~/code/api", status: "" },
  {
    id: "design",
    name: "design-tokens",
    path: "~/code/design-tokens",
    status: "Added",
  },
  { id: "infra", name: "infra", path: "~/code/infra", status: "" },
  {
    id: "legacy",
    name: "legacy-admin",
    path: "~/archive/legacy-admin",
    status: "Archived",
  },
  { id: "docs", name: "docs", path: "~/code/docs", status: "" },
];

// Already added: shown checked, and no click, key or select-all unchecks them.
const added = ["web", "design"];
// Locked rows: the added ones plus an archived one that can't be picked.
const locked = ["web", "design", "legacy"];

const nameCls = css({ fontWeight: "medium", truncate: true });
const pathCls = css({ color: "neutral.text.muted", truncate: true });
const statusCls = css({
  justifyContent: "flex-end",
  color: "neutral.text.muted",
  textStyle: "caption",
  whiteSpace: "nowrap",
});
const logCls = css({
  margin: "0",
  textStyle: "body.sm",
  color: "neutral.text.muted",
});
---

<div
  class={stack({ gap: "sm", width: "100%" })}
  data-demo-grid-list-disabled
  data-added={JSON.stringify(added)}
>
  <GridList
    label="Repositories"
    items={repositories}
    getId={(repository: Repository) => repository.id}
    selectable
    header
    disabledIds={locked}
    defaultValue={added}
    columns={[
      { id: "name", track: "minmax(8rem, 12rem)", header: "Repository" },
      { id: "path", track: "minmax(0, 1fr)", header: "Path" },
      { id: "status", track: "auto", srHeader: "Status" },
    ]}
  >
    {(ctx: GridListRender<Repository>) =>
      ctx.region === "row" ? (
        <Fragment>
          <span {...ctx.cell("name", { class: nameCls })}>{ctx.item.name}</span>
          <span {...ctx.cell("path", { class: pathCls })}>{ctx.item.path}</span>
          <span {...ctx.cell("status", { class: statusCls })}>
            {ctx.item.status}
          </span>
        </Fragment>
      ) : null
    }
  </GridList>
  <p class={logCls} aria-live="polite" data-demo-log>
    Pick repositories to add; added and archived ones are locked.
  </p>
</div>

<script>
  import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";

  for (const scope of document.querySelectorAll<HTMLElement>(
    "[data-demo-grid-list-disabled]",
  )) {
    const added = new Set<string>(JSON.parse(scope.dataset.added ?? "[]"));
    const log = scope.querySelector("[data-demo-log]");
    const idle = log?.textContent ?? "";
    scope.addEventListener(GRID_LIST_EVENTS.valueChange, (event) => {
      if (!(event instanceof CustomEvent) || !log) return;
      const value: Set<string> = event.detail?.value ?? new Set();
      const picked = [...value].filter((id) => !added.has(id)).length;
      log.textContent = picked === 0 ? idle.trim() : `${picked} to add`;
    });
  }
</script>

Pick lists and tone

A row with no primary link has nothing to open, so in a selectable list the whole row is its checkbox’s target: a click anywhere on it (outside its own controls) toggles it, Shift-click extends the range, and Enter toggles it too, unless the list has an onOpen. Rows with a primary link keep opening on click and on Enter.

tone colours the selection: the selected rows’ fill, a joined block’s fill, the check badge and every checkbox. It takes a semantic tone (primary by default, neutral, success, warning, danger). Use danger when picking means deleting.

Click a row to pick it for deletion.

---
// Hand-written: `packages/demos/grid-list/pick-list.demo.tsx` skips Astro
// (see its `skip`) — Astro GridList takes its rows through one default-slot
// callback, not a `row` render prop. The script below counts the picks from
// `grid-list:valuechange`.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

interface Branch {
  id: string;
  name: string;
  merged: string;
}

const branches: Branch[] = [
  { id: "b1", name: "fix/empty-state-spacing", merged: "2 days ago" },
  { id: "b2", name: "feat/toolbar-shortcuts", merged: "5 days ago" },
  { id: "b3", name: "chore/bump-dependencies", merged: "1 week ago" },
  { id: "b4", name: "refactor/export-button", merged: "2 weeks ago" },
  { id: "b5", name: "feat/search-cache", merged: "3 weeks ago" },
];

const nameCls = css({ fontFamily: "mono", truncate: true });
const mergedCls = css({
  justifyContent: "flex-end",
  color: "neutral.text.muted",
  textStyle: "caption",
  whiteSpace: "nowrap",
});
const logCls = css({
  margin: "0",
  textStyle: "body.sm",
  color: "neutral.text.muted",
});
---

<div class={stack({ gap: "sm", width: "100%" })} data-demo-grid-list-pick>
  <GridList
    label="Merged branches"
    items={branches}
    getId={(branch: Branch) => branch.id}
    selectable
    selectAll="footer"
    tone="danger"
    columns={[
      { id: "name", track: "minmax(0, 1fr)" },
      { id: "merged", track: "auto" },
    ]}
  >
    {(ctx: GridListRender<Branch>) =>
      ctx.region === "row" ? (
        <Fragment>
          <span {...ctx.cell("name", { class: nameCls })}>{ctx.item.name}</span>
          <span {...ctx.cell("merged", { class: mergedCls })}>
            {ctx.item.merged}
          </span>
        </Fragment>
      ) : null
    }
  </GridList>
  <p class={logCls} aria-live="polite" data-demo-log>
    Click a row to pick it for deletion.
  </p>
</div>

<script>
  import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";

  for (const scope of document.querySelectorAll<HTMLElement>(
    "[data-demo-grid-list-pick]",
  )) {
    const log = scope.querySelector("[data-demo-log]");
    const idle = log?.textContent?.trim() ?? "";
    scope.addEventListener(GRID_LIST_EVENTS.valueChange, (event) => {
      if (!(event instanceof CustomEvent) || !log) return;
      const size: number = event.detail?.value?.size ?? 0;
      log.textContent = size === 0 ? idle : `${size} to delete`;
    });
  }
</script>

Select-all

selectAll says where the select-all checkbox goes. By default ("header") it sits in the column header’s select column (or over the selectOver column’s header), so it needs header. A list without a header, like the inbox above, can use "footer" instead: a labelled “Select all” checkbox in the row under the list, before the pager if there is one. A grid of tiles can use it too. false shows no select-all checkbox anywhere. Wherever it sits, it covers the same items, and ⌘A works in every case.

When a BulkBar acts on the list, give it somewhere to put the focus back once the selection empties under its buttons: returnFocus={() => gridListFocusTarget(listElement)}. The helper, exported by every GridList package, returns the list’s select-all checkbox (header or footer) while it is enabled, else the row holding the list’s Tab stop.

Cell navigation

The list follows the ARIA grid pattern. The rows rove, and every control inside a row is taken out of the Tab order (tabindex="-1"): its checkbox, buttons, and links other than the primary one. The list sets this itself, including on rows that render later, so Tab leaves the grid in one stop. The keys reach the controls instead:

Key (in a row’s control)Action
→ / ←Next / previous control in the row
Home / EndFirst / last control
← on the first, EscBack to the row
F2Back to the row (on a row, F2 moves in as → does)
↑ / ↓Back to the row, then to the previous / next row

A text field in a row keeps its arrows, Home and End for the caret, so only Esc and F2 leave it. In the grid layout, ← / → already move between tiles, so only F2 moves into a tile’s controls. Controls that are hidden while the row has focus are skipped, for example a reveal="rest" cell or the cell under selectOver’s checkbox. The logic is gridListCellKeydown and gridListRowControls in @pindoba/core-grid-list.

The keyboard model lives in pure reducers in @pindoba/core-grid-list (gridListKeydown, toggleGridListRow, toggleGridListAll), so every framework gets the same behaviour.

selectOver

Without selectOver, a selectable list adds its own select column, along with its place in the narrow template. That track sits blank at the start of every row while no checkbox shows. When rows already start with a leading cell (a status or file-type icon, an avatar), selectOver="<column>" puts the checkbox over that cell instead. The cell shows at rest and gives way to the checkbox on hover, focus or selection, and on every row once anything is selected. Touch screens have no hover, so the cell stays at rest there. A long press on the row, or a tap on the cell (the transparent checkbox sits above it), selects the row; from then on every row shows its checkbox, as on a phone’s mail app. A list’s own select column and tiles always show their checkboxes on touch. The header’s select-all takes that column’s header, centred over the rows’ checkboxes. On a narrow layout the checkbox folds with its host cell, unless narrow.cells.select places it. Give the host column an auto track: it takes the wider of the cell and the checkbox, and the list pads it like its own select column, so the checkbox sits as far from the text as from the row’s edge and the cell’s content centres on the checkbox.

A disabled row (disabledIds) keeps its cell in every state, and the checkbox never shows over it. Render the reason there: the locked files below keep their lock icon, while the other files’ type icons give way to the checkbox. The drag demo below puts the checkbox over an avatar.

Hover a file to pick it; locked files keep their lock.

---
// Hand-written: `packages/demos/grid-list/select-over.demo.tsx` skips Astro
// (see its `skip`) — Astro GridList takes its rows through one default-slot
// callback, not a `row` render prop. The script below counts the selection
// from `grid-list:valuechange`.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { FileText, Image, Lock, Sheet } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

interface Doc {
  id: string;
  name: string;
  kind: string;
  size: string;
  locked: boolean;
}

const files: Doc[] = [
  {
    id: "brief",
    name: "Project brief.md",
    kind: "text",
    size: "4 KB",
    locked: false,
  },
  {
    id: "budget",
    name: "Budget 2027.csv",
    kind: "sheet",
    size: "18 KB",
    locked: true,
  },
  {
    id: "cover",
    name: "Cover photo.jpg",
    kind: "image",
    size: "2.1 MB",
    locked: false,
  },
  {
    id: "notes",
    name: "Meeting notes.md",
    kind: "text",
    size: "7 KB",
    locked: false,
  },
  {
    id: "contract",
    name: "Signed contract.pdf",
    kind: "text",
    size: "310 KB",
    locked: true,
  },
  {
    id: "chart",
    name: "Growth chart.png",
    kind: "image",
    size: "640 KB",
    locked: false,
  },
];

// Locked files can't be picked: their lock icon stays where the checkbox
// would show, and tells why.
const locked = files.filter((file) => file.locked).map((file) => file.id);

const iconCls = css({ color: "neutral.text.muted" });
const nameCls = css({ fontWeight: "medium", truncate: true });
const sizeCls = css({
  justifyContent: "flex-end",
  color: "neutral.text.muted",
  textStyle: "caption",
  whiteSpace: "nowrap",
});
const logCls = css({
  margin: "0",
  textStyle: "body.sm",
  color: "neutral.text.muted",
});
---

<div
  class={stack({ gap: "sm", width: "100%" })}
  data-demo-grid-list-select-over
>
  <GridList
    label="Files"
    items={files}
    getId={(file: Doc) => file.id}
    selectable
    header
    selectOver="icon"
    disabledIds={locked}
    columns={[
      { id: "icon", track: "auto", srHeader: "Type" },
      { id: "name", track: "minmax(0, 1fr)", header: "Name" },
      { id: "size", track: "auto", header: "Size" },
    ]}
  >
    {(ctx: GridListRender<Doc>) =>
      ctx.region === "row" ? (
        <Fragment>
          <span {...ctx.cell("icon", { class: iconCls })}>
            {ctx.item.locked ? (
              <Lock size={14} />
            ) : ctx.item.kind === "sheet" ? (
              <Sheet size={14} />
            ) : ctx.item.kind === "image" ? (
              <Image size={14} />
            ) : (
              <FileText size={14} />
            )}
          </span>
          <span {...ctx.cell("name", { class: nameCls })}>{ctx.item.name}</span>
          <span {...ctx.cell("size", { class: sizeCls })}>{ctx.item.size}</span>
        </Fragment>
      ) : null
    }
  </GridList>
  <p class={logCls} aria-live="polite" data-demo-log>
    Hover a file to pick it; locked files keep their lock.
  </p>
</div>

<script>
  import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";

  for (const scope of document.querySelectorAll<HTMLElement>(
    "[data-demo-grid-list-select-over]",
  )) {
    const log = scope.querySelector("[data-demo-log]");
    const idle = log?.textContent ?? "";
    scope.addEventListener(GRID_LIST_EVENTS.valueChange, (event) => {
      if (!(event instanceof CustomEvent) || !log) return;
      const value: Set<string> = event.detail?.value ?? new Set();
      log.textContent =
        value.size === 0 ? idle.trim() : `${value.size} selected`;
    });
  }
</script>

Row actions

actions gives each row its actions (archive, star, delete) as data: an id, a label (the icon button’s name and tooltip, and the menu item’s text), an icon, and optionally tone: "danger", disabled and a shortcut. Pass one list for every row, or a function of the row’s item when the actions depend on it (Star or Unstar). onAction(actionId, item) reports every run, and an action’s own onSelect(item) runs first when it has one.

Each action is offered in several ways, all from the same list:

  • Mouse. The first actionsInline enabled actions (2 by default) show as icon buttons while the row is hovered or focused. The rest wait behind a ⋯ button, which shows only when there is something behind it. actionsInline={0} keeps every action in the menu.
  • Menu. With a mouse, the ⋯ menu lists only the actions that aren’t icon buttons: the buttons sit right beside it, so it never repeats them. A right-click on the row, Shift+F10 and the ContextMenu key open the same menu with every action, the inline ones included, on any device: they open it away from the buttons. A right-click opens it at the pointer and extends down and to the right of it, like a desktop context menu; the keys open it at the row’s ⋯, lined up with the button’s end edge. The browser’s own menu stays closed only on rows that have actions. When the menu closes, focus goes back to the row.
  • Shortcuts. A shortcut is one key with no modifier ("e", "Delete"). It runs its action on the focused row, or on a control inside it, and shows in the button’s tooltip and the menu item. The keys the list uses itself (x, Space, Enter, the arrows, j / k, Home / End, PageUp / PageDown, Esc, F2, Tab) are refused: the list throws on its first render, so a clash never silently takes over selection or navigation. A shortcut does nothing while focus is in a text field.
  • Touch. Touch screens have no hover, so the icon buttons never show there. The ⋯ button shows at rest on every row instead, and its menu holds every action. A tap on ⋯ counts as touch on any device (a touch laptop too), so its menu lists every action as well.

What the menu lists is decided when it opens, by core’s gridListActionsMenuScope({ via, pointerType }) and passed to rowActions(item, id, { menu }) ("all" or "overflow"). Where it opens is the at option: a right-click’s point (gridListContextMenuPoint(event)) makes the menu bag’s anchorPoint and a bottom-start placement, none keeps it at ⋯ (bottom-end, gridListActionsMenuPlacement). Every framework and the Astro runtime use the same rules.

A disabled action stays in the menu, greyed out, but never becomes an icon button and its shortcut does nothing. Rows in disabledIds keep their actions: disabledIds only takes a row out of the selection. While anything is selected, row actions are off everywhere (buttons, ⋯, the right-click menu and shortcuts), and the selection’s Bulk Bar acts instead. Destructive actions run at once, with no confirm: show an undo toast from onAction.

Without actionsOver, the list adds an actions column of its own at the end of each row (named by labels.actionsColumn). actionsOver="<column>" lays the actions over that column’s cell instead, so no track sits empty at the end of the row: the cell, often a date, gives way to the buttons on hover and focus, as selectOver does for the checkbox. On touch the cell keeps its content and the ⋯ sits beside it. On a narrow layout the actions fold with their host cell, or with their own column, unless narrow.cells.actions places them. Tiles (layout="grid") have no icon buttons: their ⋯ sits in the corner opposite the checkbox, shown on hover and always on touch.

The inbox below lays its actions over the date. Archive and Star are icon buttons; Mark as unread and Delete are in the ⋯ menu. Right-click a row to see all four, press E, S, U or Delete on a focused row, or select one to see the actions step aside.

Hover a message, right-click it, or press E on it.

---
// Hand-written: `packages/demos/grid-list/actions.demo.tsx` skips Astro (see
// its `skip`) — Astro GridList actions are data: each icon is the named slot
// `action-<id>`, and a run is the `grid-list:action` event (`detail: {
// action, id }`), which the script below writes to the log. The list itself
// stays as the server rendered it (the other frameworks drop archived rows).
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { Archive, Mail as MailIcon, Star, Trash } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

interface Mail {
  id: string;
  from: string;
  subject: string;
  date: string;
  starred: boolean;
}

const inbox: Mail[] = [
  {
    id: "m1",
    from: "Inês Duarte",
    subject: "Venue shortlist for the offsite",
    date: "09:41",
    starred: true,
  },
  {
    id: "m2",
    from: "Kofi Mensah",
    subject: "Receipts from the March trip",
    date: "Yesterday",
    starred: false,
  },
  {
    id: "m3",
    from: "Yuna Choi",
    subject: "Draft agenda, second pass",
    date: "Oct 24",
    starred: false,
  },
  {
    id: "m4",
    from: "Tomás Reyes",
    subject: "Catering quotes",
    date: "Oct 19",
    starred: false,
  },
];

// The same actions for every row, but the star one reads the row: the
// function form gives each row its own list.
function actionsFor(mail: Mail) {
  return [
    { id: "archive", label: "Archive", shortcut: "e" },
    { id: "star", label: mail.starred ? "Unstar" : "Star", shortcut: "s" },
    { id: "unread", label: "Mark as unread", shortcut: "u" },
    {
      id: "delete",
      label: "Delete",
      tone: "danger" as const,
      shortcut: "Delete",
    },
  ];
}

// What the script needs to word the log: each message's subject and star.
const mailData = JSON.stringify(
  Object.fromEntries(
    inbox.map((mail) => [mail.id, [mail.subject, mail.starred]]),
  ),
);

const fromCls = css({ fontWeight: "medium", truncate: true });
const subjectCls = css({ color: "neutral.text.muted", truncate: true });
const dateCls = css({
  justifyContent: "flex-end",
  color: "neutral.text.muted",
  fontVariantNumeric: "tabular-nums",
  whiteSpace: "nowrap",
});
const logCls = css({
  margin: "0",
  textStyle: "body.sm",
  color: "neutral.text.muted",
});
---

<div
  class={stack({ gap: "sm", width: "100%" })}
  data-demo-grid-list-actions
  data-demo-mail={mailData}
>
  <GridList
    label="Inbox"
    items={inbox}
    getId={(mail: Mail) => mail.id}
    selectable
    actions={actionsFor}
    actionsOver="date"
    columns={[
      { id: "from", track: "minmax(0, 16rem)" },
      { id: "subject", track: "minmax(0, 1fr)" },
      { id: "date", track: "auto" },
    ]}
    narrow={{
      columns: "auto minmax(0, 1fr) auto",
      cells: {
        from: { column: "2", row: "1" },
        date: { column: "3", row: "1" },
        subject: { column: "2 / -1", row: "2" },
      },
    }}
  >
    <Archive slot="action-archive" />
    <Star slot="action-star" />
    <MailIcon slot="action-unread" />
    <Trash slot="action-delete" />
    {(ctx: GridListRender<Mail>) =>
      ctx.region === "row" ? (
        <Fragment>
          <span {...ctx.cell("from", { class: fromCls })}>{ctx.item.from}</span>
          <span {...ctx.cell("subject", { class: subjectCls })}>
            {ctx.item.starred && <Star size={12} />}
            {ctx.item.subject}
          </span>
          <span {...ctx.cell("date", { class: dateCls })}>{ctx.item.date}</span>
        </Fragment>
      ) : null
    }
  </GridList>
  <p class={logCls} aria-live="polite" data-demo-log>
    Hover a message, right-click it, or press E on it.
  </p>
</div>

<script>
  import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";

  for (const scope of document.querySelectorAll<HTMLElement>(
    "[data-demo-grid-list-actions]",
  )) {
    const log = scope.querySelector("[data-demo-log]");
    const mail: Record<string, [string, boolean]> = JSON.parse(
      scope.dataset.demoMail ?? "{}",
    );
    scope.addEventListener(GRID_LIST_EVENTS.action, (event) => {
      if (!(event instanceof CustomEvent) || !log) return;
      const { action, id } = event.detail ?? {};
      const [subject, starred] = mail[id] ?? ["", false];
      const done: Record<string, string> = {
        archive: "Archived",
        delete: "Deleted",
        star: starred ? "Unstarred" : "Starred",
        unread: "Marked as unread",
      };
      log.textContent = `${done[action] ?? action}: ${subject}`;
    });
  }
</script>

Swipe actions

On a touch screen the row itself can reveal actions. Give an action swipe: "start" or swipe: "end" and its filled tile sits behind the row on that side: "start" slides the row toward the end edge, "end" toward the start edge, and under RTL the sides mirror. Several actions on one side stack into one strip. Swipe is an accelerator: every swipe action stays in the ⋯ menu, the context menu and its shortcut, so nothing depends on the gesture.

swipeCommit: true on the first action of a side runs it on a full swipe or a flick, with no tap: the tile stretches once a release would run it. Without it a swipe only reveals the tiles, and a tap on one runs it. Destructive actions skip the confirm either way: show an undo toast from onAction, which gets onAction(id, item, { via: "swipe" }) for a swipe (Astro: detail.via). via is absent when the action ran from a button, the menu or a shortcut.

The rules, so the gesture never fights the rest of the list:

  • Touch and pen only. A mouse never swipes a row.
  • On a draggable list the drag wins over the hold, and the swipe starts only from a horizontal move. On a list that isn’t draggable, a hold still selects the row.
  • Swipe is off while anything is selected, like every row action.
  • Tiles (layout="grid") and disabled actions ignore swipe.

Archive swipes in from the start and commits on a full swipe, Delete swipes in from the end and reveals its tile, and Star has no swipe. Both run with an undo toast. Use a touch device or its emulation to try it.

Swipe a message on a touch screen, or use its buttons.

---
// Hand-written: `packages/demos/grid-list/row-actions-swipe.demo.tsx` skips Astro (see
// its `skip`) — Astro GridList actions are data: each icon is the named slot
// `action-<id>`, and a run is the `grid-list:action` event (`detail: {
// action, id, via }`), which the script below writes to the log. The list itself
// stays as the server rendered it (the other frameworks drop archived rows).
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { Archive, Star, Trash } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

interface Mail {
  id: string;
  from: string;
  subject: string;
  date: string;
  starred: boolean;
}

const inbox: Mail[] = [
  {
    id: "m1",
    from: "Inês Duarte",
    subject: "Venue shortlist for the offsite",
    date: "09:41",
    starred: true,
  },
  {
    id: "m2",
    from: "Kofi Mensah",
    subject: "Receipts from the March trip",
    date: "Yesterday",
    starred: false,
  },
  {
    id: "m3",
    from: "Yuna Choi",
    subject: "Draft agenda, second pass",
    date: "Oct 24",
    starred: false,
  },
  {
    id: "m4",
    from: "Tomás Reyes",
    subject: "Catering quotes",
    date: "Oct 19",
    starred: false,
  },
];

// The same actions for every row, but the star one reads the row: the
// function form gives each row its own list.
function actionsFor(mail: Mail) {
  return [
    {
      id: "archive",
      label: "Archive",
      shortcut: "e",
      swipe: "start" as const,
      swipeCommit: true,
    },
    { id: "star", label: mail.starred ? "Unstar" : "Star", shortcut: "s" },
    {
      id: "delete",
      label: "Delete",
      tone: "danger" as const,
      shortcut: "Delete",
      swipe: "end" as const,
    },
  ];
}

// What the script needs to word the log: each message's subject and star.
const mailData = JSON.stringify(
  Object.fromEntries(
    inbox.map((mail) => [mail.id, [mail.subject, mail.starred]]),
  ),
);

const fromCls = css({ fontWeight: "medium", truncate: true });
const subjectCls = css({ color: "neutral.text.muted", truncate: true });
const dateCls = css({
  justifyContent: "flex-end",
  color: "neutral.text.muted",
  fontVariantNumeric: "tabular-nums",
  whiteSpace: "nowrap",
});
const logCls = css({
  margin: "0",
  textStyle: "body.sm",
  color: "neutral.text.muted",
});
---

<div
  class={stack({ gap: "sm", width: "100%" })}
  data-demo-grid-list-row-actions-swipe
  data-demo-mail={mailData}
>
  <GridList
    label="Inbox"
    items={inbox}
    getId={(mail: Mail) => mail.id}
    selectable
    actions={actionsFor}
    actionsOver="date"
    columns={[
      { id: "from", track: "minmax(0, 16rem)" },
      { id: "subject", track: "minmax(0, 1fr)" },
      { id: "date", track: "auto" },
    ]}
    narrow={{
      columns: "auto minmax(0, 1fr) auto",
      cells: {
        from: { column: "2", row: "1" },
        date: { column: "3", row: "1" },
        subject: { column: "2 / -1", row: "2" },
      },
    }}
  >
    <Archive slot="action-archive" />
    <Star slot="action-star" />
    <Trash slot="action-delete" />
    {(ctx: GridListRender<Mail>) =>
      ctx.region === "row" ? (
        <Fragment>
          <span {...ctx.cell("from", { class: fromCls })}>{ctx.item.from}</span>
          <span {...ctx.cell("subject", { class: subjectCls })}>
            {ctx.item.starred && <Star size={12} />}
            {ctx.item.subject}
          </span>
          <span {...ctx.cell("date", { class: dateCls })}>{ctx.item.date}</span>
        </Fragment>
      ) : null
    }
  </GridList>
  <p class={logCls} aria-live="polite" data-demo-log>
    Swipe a message on a touch screen, or use its buttons.
  </p>
</div>

<script>
  import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";

  for (const scope of document.querySelectorAll<HTMLElement>(
    "[data-demo-grid-list-row-actions-swipe]",
  )) {
    const log = scope.querySelector("[data-demo-log]");
    const mail: Record<string, [string, boolean]> = JSON.parse(
      scope.dataset.demoMail ?? "{}",
    );
    scope.addEventListener(GRID_LIST_EVENTS.action, (event) => {
      if (!(event instanceof CustomEvent) || !log) return;
      const { action, id, via } = event.detail ?? {};
      const [subject, starred] = mail[id] ?? ["", false];
      const done: Record<string, string> = {
        archive: "Archived",
        delete: "Deleted",
        star: starred ? "Unstarred" : "Starred",
      };
      log.textContent = `${done[action] ?? action}: ${subject} (via ${via ?? "button or menu"})`;
    });
  }
</script>

Without actionsOver, the list gives the actions a trailing column of their own, with an Actions header. Here actionsInline={0} keeps every action in the menu, so ⋯ is the row’s only control: it shows on hover with a mouse and at rest on touch. Locked files can’t be shared or trashed: the function form marks those two disabled for them, so they stay in the menu, greyed out, and their shortcuts do nothing. The locked files are also in disabledIds, which only takes them out of the selection: they can’t be picked (the lock stays where the checkbox would show), but they keep their menu and can still be downloaded. Pick an unlocked file and the ⋯ buttons, the right-click menu and the shortcuts all step aside until the selection is cleared. Below 640px the menu folds onto the name’s line (narrow.cells.actions).

Open a file's ⋯, right-click it, or press D on it.

---
// Hand-written: `packages/demos/grid-list/actions-column.demo.tsx` skips
// Astro (see its `skip`) — Astro GridList takes its rows through one
// default-slot callback, each action icon through the named slot
// `action-<id>`, and reports a run as the `grid-list:action` event
// (`detail: { action, id }`), which the script below writes to the log. The
// list itself stays as the server rendered it (the other frameworks drop a
// trashed file).
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import {
  Download,
  FileText,
  Image,
  Link,
  Lock,
  Share2,
  Sheet,
  Trash,
} from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

interface Doc {
  id: string;
  name: string;
  kind: string;
  size: string;
  modified: string;
  locked: boolean;
}

const files: Doc[] = [
  {
    id: "brief",
    name: "Project brief.md",
    kind: "text",
    size: "4 KB",
    modified: "Today",
    locked: false,
  },
  {
    id: "budget",
    name: "Budget 2027.csv",
    kind: "sheet",
    size: "18 KB",
    modified: "Yesterday",
    locked: true,
  },
  {
    id: "cover",
    name: "Cover photo.jpg",
    kind: "image",
    size: "2.1 MB",
    modified: "Oct 28",
    locked: false,
  },
  {
    id: "contract",
    name: "Signed contract.pdf",
    kind: "text",
    size: "310 KB",
    modified: "Oct 21",
    locked: true,
  },
  {
    id: "notes",
    name: "Meeting notes.md",
    kind: "text",
    size: "7 KB",
    modified: "Oct 17",
    locked: false,
  },
];

// Locked files can't be picked (their lock stays where the checkbox would
// show) — but `disabledIds` is about selection only: they keep their actions.
const locked = files.filter((file) => file.locked).map((file) => file.id);

// A locked file still downloads and copies its link, but can't be shared
// or trashed: those stay in its menu, greyed out.
function actionsFor(file: Doc) {
  return [
    { id: "download", label: "Download", shortcut: "d" },
    { id: "link", label: "Copy link", shortcut: "l" },
    { id: "share", label: "Share", shortcut: "s", disabled: file.locked },
    {
      id: "trash",
      label: "Move to trash",
      tone: "danger" as const,
      shortcut: "Delete",
      disabled: file.locked,
    },
  ];
}

// What the script needs to word the log: each file's name.
const names = JSON.stringify(
  Object.fromEntries(files.map((file) => [file.id, file.name])),
);

const iconCls = css({ color: "neutral.text.muted" });
const nameCls = css({ fontWeight: "medium", truncate: true });
const metaCls = css({
  color: "neutral.text.muted",
  textStyle: "caption",
  fontVariantNumeric: "tabular-nums",
  whiteSpace: "nowrap",
});
const logCls = css({
  margin: "0",
  textStyle: "body.sm",
  color: "neutral.text.muted",
});
---

<div
  class={stack({ gap: "sm", width: "100%" })}
  data-demo-grid-list-actions-column
  data-demo-names={names}
>
  <GridList
    label="Files"
    items={files}
    getId={(file: Doc) => file.id}
    selectable
    header
    selectOver="icon"
    disabledIds={locked}
    actions={actionsFor}
    actionsInline={0}
    columns={[
      { id: "icon", track: "auto", srHeader: "Type" },
      { id: "name", track: "minmax(0, 1fr)", header: "Name" },
      { id: "size", track: "auto", header: "Size" },
      { id: "modified", track: "auto", header: "Modified" },
    ]}
    narrow={{
      columns: "auto minmax(0, 1fr) auto",
      // Folded: the menu stays on the name's line, the size and date
      // drop below it.
      cells: {
        icon: { column: "1", row: "1" },
        name: { column: "2", row: "1" },
        actions: { column: "3", row: "1" },
        size: { column: "2", row: "2" },
        modified: { column: "3", row: "2" },
      },
    }}
  >
    <Download slot="action-download" />
    <Link slot="action-link" />
    <Share2 slot="action-share" />
    <Trash slot="action-trash" />
    {(ctx: GridListRender<Doc>) =>
      ctx.region === "row" ? (
        <Fragment>
          <span {...ctx.cell("icon", { class: iconCls })}>
            {ctx.item.locked ? (
              <Lock size={14} />
            ) : ctx.item.kind === "sheet" ? (
              <Sheet size={14} />
            ) : ctx.item.kind === "image" ? (
              <Image size={14} />
            ) : (
              <FileText size={14} />
            )}
          </span>
          <span {...ctx.cell("name", { class: nameCls })}>{ctx.item.name}</span>
          <span {...ctx.cell("size", { class: metaCls })}>{ctx.item.size}</span>
          <span {...ctx.cell("modified", { class: metaCls })}>
            {ctx.item.modified}
          </span>
        </Fragment>
      ) : null
    }
  </GridList>
  <p class={logCls} aria-live="polite" data-demo-log>
    Open a file's ⋯, right-click it, or press D on it.
  </p>
</div>

<script>
  import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";

  for (const scope of document.querySelectorAll<HTMLElement>(
    "[data-demo-grid-list-actions-column]",
  )) {
    const log = scope.querySelector("[data-demo-log]");
    let last = log?.textContent?.trim() ?? "";
    const names: Record<string, string> = JSON.parse(
      scope.dataset.demoNames ?? "{}",
    );
    const done: Record<string, string> = {
      download: "Downloading",
      link: "Link copied",
      share: "Sharing",
      trash: "Moved to trash",
    };
    scope.addEventListener(GRID_LIST_EVENTS.action, (event) => {
      if (!(event instanceof CustomEvent) || !log) return;
      const { action, id } = event.detail ?? {};
      last = `${done[action] ?? action}: ${names[id] ?? id}`;
      log.textContent = last;
    });
    scope.addEventListener(GRID_LIST_EVENTS.valueChange, (event) => {
      if (!(event instanceof CustomEvent) || !log) return;
      const value: Set<string> = event.detail?.value ?? new Set();
      log.textContent = value.size === 0 ? last : `${value.size} selected`;
    });
  }
</script>

Tiles

Tiles have no icon buttons, whatever actionsInline says: every action is in the ⋯ menu, which sits in the tile’s top-end corner, opposite the checkbox. It is a filled button, like the checkbox’s box, so it stays legible over a photo. It shows on hover and focus with a mouse and always on touch. A right-click on a tile opens the same menu at the pointer, and the shortcuts (F, D, S, Delete) work on a focused tile.

Hover a photo for its ⋯, or right-click it.

---
// Hand-written: `packages/demos/grid-list/actions-tiles.demo.tsx` skips
// Astro (see its `skip`) — Astro GridList takes its tiles through one
// default-slot callback, each action icon through the named slot
// `action-<id>`, and reports a run as the `grid-list:action` event
// (`detail: { action, id }`), which the script below writes to the log. The
// grid itself stays as the server rendered it (the other frameworks drop a
// deleted photo and flip its favourite).
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { Download, Share2, Star, Trash } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

interface Picture {
  id: string;
  title: string;
  meta: string;
  seed: string;
  favourite: boolean;
}

const pictures: Picture[] = [
  {
    id: "p1",
    title: "Morning fog",
    meta: "Sintra · 4032 × 3024",
    seed: "pindoba-fog",
    favourite: true,
  },
  {
    id: "p2",
    title: "Salt flats",
    meta: "Uyuni · 6000 × 4000",
    seed: "pindoba-salt",
    favourite: false,
  },
  {
    id: "p3",
    title: "Tram 28",
    meta: "Lisbon · 4032 × 3024",
    seed: "pindoba-tram",
    favourite: false,
  },
  {
    id: "p4",
    title: "Night market",
    meta: "Taipei · 6000 × 4000",
    seed: "pindoba-market",
    favourite: false,
  },
  {
    id: "p5",
    title: "Rooftops",
    meta: "Porto · 5472 × 3648",
    seed: "pindoba-roofs",
    favourite: true,
  },
];

// Tiles have no icon buttons: every action is in the corner ⋯ menu.
function actionsFor(picture: Picture) {
  return [
    {
      id: "favourite",
      label: picture.favourite ? "Remove from favourites" : "Favourite",
      shortcut: "f",
    },
    { id: "download", label: "Download", shortcut: "d" },
    { id: "share", label: "Share", shortcut: "s" },
    {
      id: "delete",
      label: "Delete",
      tone: "danger" as const,
      shortcut: "Delete",
    },
  ];
}

// What the script needs to word the log: each photo's title and favourite.
const photoData = JSON.stringify(
  Object.fromEntries(
    pictures.map((picture) => [picture.id, [picture.title, picture.favourite]]),
  ),
);

const imageCls = css({
  display: "block",
  width: "100%",
  aspectRatio: "4 / 3",
  objectFit: "cover",
  borderRadius: "sm",
  background: "neutral.surface.valley",
});
const titleCls = css({ fontWeight: "medium", truncate: true });
const metaCls = css({
  color: "neutral.text.muted",
  textStyle: "caption",
  fontVariantNumeric: "tabular-nums",
  truncate: true,
});
const logCls = css({
  margin: "0",
  textStyle: "body.sm",
  color: "neutral.text.muted",
});
---

<div
  class={stack({ gap: "sm", width: "100%" })}
  data-demo-grid-list-actions-tiles
  data-demo-photos={photoData}
>
  <GridList
    label="Photos"
    layout="grid"
    tileSize="14rem"
    items={pictures}
    getId={(picture: Picture) => picture.id}
    selectable
    actions={actionsFor}
  >
    <Star slot="action-favourite" />
    <Download slot="action-download" />
    <Share2 slot="action-share" />
    <Trash slot="action-delete" />
    {(ctx: GridListRender<Picture>) =>
      ctx.region === "row" ? (
        <Fragment>
          <img
            class={imageCls}
            src={`https://picsum.photos/seed/${ctx.item.seed}/400/300`}
            alt=""
            loading="lazy"
            width="400"
            height="300"
          />
          <span class={titleCls}>
            {ctx.item.favourite ? `★ ${ctx.item.title}` : ctx.item.title}
          </span>
          <span class={metaCls}>{ctx.item.meta}</span>
        </Fragment>
      ) : null
    }
  </GridList>
  <p class={logCls} aria-live="polite" data-demo-log>
    Hover a photo for its ⋯, or right-click it.
  </p>
</div>

<script>
  import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";

  for (const scope of document.querySelectorAll<HTMLElement>(
    "[data-demo-grid-list-actions-tiles]",
  )) {
    const log = scope.querySelector("[data-demo-log]");
    let last = log?.textContent?.trim() ?? "";
    const photos: Record<string, [string, boolean]> = JSON.parse(
      scope.dataset.demoPhotos ?? "{}",
    );
    scope.addEventListener(GRID_LIST_EVENTS.action, (event) => {
      if (!(event instanceof CustomEvent) || !log) return;
      const { action, id } = event.detail ?? {};
      const [title, favourite] = photos[id] ?? [id, false];
      const done: Record<string, string> = {
        favourite: favourite
          ? "Removed from favourites"
          : "Added to favourites",
        download: "Downloading",
        share: "Sharing",
        delete: "Deleted",
      };
      last = `${done[action] ?? action}: ${title}`;
      log.textContent = last;
    });
    scope.addEventListener(GRID_LIST_EVENTS.valueChange, (event) => {
      if (!(event instanceof CustomEvent) || !log) return;
      const value: Set<string> = event.detail?.value ?? new Set();
      log.textContent = value.size === 0 ? last : `${value.size} selected`;
    });
  }
</script>

An action’s icon is a component that renders with no props, in every framework, as in the Bulk Bar: pass the Lucide icon itself (icon: Archive), not an element. Each framework exports the type as GridListIcon: a Svelte Component, a React ComponentType, a Vue Component and a Solid component. The list draws it bare in the icon button and in a ghost Stamp as the menu item’s leading icon. In Astro, actions are data only. Each icon is the list’s named slot action-<id>, and every run dispatches grid-list:action on the list, with detail: { action, id }:

<GridList
  items={mail}
  getId={(m) => m.id}
  actionsOver="date"
  actions={[
    { id: "archive", label: "Archive", shortcut: "e" },
    { id: "delete", label: "Delete", tone: "danger", shortcut: "Delete" },
  ]}
>
  <Archive slot="action-archive" />
  <Trash slot="action-delete" />
  {(ctx: GridListRender<Mail>) => …}
</GridList>

<script>
  import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";
  list.addEventListener(GRID_LIST_EVENTS.action, (event) => {
    const { action, id } = event.detail;
  });
</script>

A row added on the client (an item passed through update({ items }) that the server never rendered) is drawn by renderRow. renderRow returns only the row’s content cells: the list draws the actions cell around them, the same as on a server row. That cell holds the icon buttons and their tooltips, the ⋯ menu, the right-click menu and the shortcuts. The icons are copies of the action-<id> slots, which the list renders once into a template. The menu and tooltip code loads only when a row has actions.

The template only holds the action ids the server rendered. An action that first shows up on the client can bring its own icon: a DOM node, such as an <svg> element. The list copies it into the icon button, the menu item and the swipe tile, and leaves your node where it is. An action’s own icon wins over the template’s, and an action with neither has no icon. The icon is a node, not an HTML string, so no markup is ever parsed from your data.

const pin = document.createElementNS("http://www.w3.org/2000/svg", "svg");
// …draw the glyph…
handle.update({
  items: [
    ...items,
    { id: "n1", actions: [{ id: "pin", label: "Pin", icon: pin }] },
  ],
});

The resolution (which actions a row has, which are inline, the menu items) lives in connectGridList’s rowActions, and the keys and gestures in gridListActionKeydown and gridListContextMenuRow in @pindoba/core-grid-list. The rule behind the touch path is docs/rules/hover-actions-touch-path.md.

Groups

groupOf(item) names each item’s group. Items must arrive sorted by it. Each group gets a title row with a chevron that collapses it, the group name and its count. Title rows stick to the top of the scroll container as you scroll, and so does the header. Set --gl-sticky-top to clear a sticky toolbar above. The stuck rows paint the opaque surface under the list (the nearest filled Panel’s, else the page’s), so rows never show through them. On a surface you painted by hand, set data-surface-floor="<step>" on it, or --gl-surface on the list.

groupLeading adds content after the chevron (an icon, a badge), and groupTrailing adds actions at the end of the title row. Both receive { name, items, open, selectedCount }, so an action can count the group’s selection. Collapsed rows drop out of the keyboard order, but select-all (the header checkbox and ⌘A) still takes every item.

groupSelect puts a select-all checkbox at the start of each title row (as below). It selects or clears the group’s rows, collapsed ones included and disabledIds left alone, and goes indeterminate while only some are selected. It is a real control, so Tab reaches it like the chevron. Its label comes from labels.selectGroup ("Select all in {name}").

Select orders, then print their labels from a section title.

---
// Hand-written: `packages/demos/grid-list/groups.demo.tsx` skips Astro (see
// its `skip`) — Astro GridList takes its rows and group title regions through
// one default-slot callback, not render props. Group titles render once on
// the server, so "Print labels" reads the live selection from the list's
// handle when clicked instead of disabling itself.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import Button from "@pindoba/astro-button";
import Stamp from "@pindoba/astro-stamp";
import { Package } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

interface Order {
  id: string;
  number: string;
  customer: string;
  items: string;
  status: string;
  total: string;
  paid: boolean;
}

// Items arrive sorted by group: each run of one status is a section.
const orders: Order[] = [
  {
    id: "o1",
    number: "#3184",
    customer: "Inês Duarte",
    items: "Linen shirt, Canvas tote",
    status: "To pack",
    total: "$148.00",
    paid: false,
  },
  {
    id: "o2",
    number: "#3183",
    customer: "Kofi Mensah",
    items: "Wool scarf",
    status: "To pack",
    total: "$62.00",
    paid: true,
  },
  {
    id: "o3",
    number: "#3181",
    customer: "Yuna Choi",
    items: "Leather wallet, Key ring",
    status: "To pack",
    total: "$95.50",
    paid: true,
  },
  {
    id: "o4",
    number: "#3179",
    customer: "Tomás Rivera",
    items: "Rain jacket",
    status: "Ready to ship",
    total: "$210.00",
    paid: true,
  },
  {
    id: "o5",
    number: "#3176",
    customer: "Amara Okafor",
    items: "Ceramic mug set",
    status: "Ready to ship",
    total: "$44.00",
    paid: true,
  },
  {
    id: "o6",
    number: "#3170",
    customer: "Lena Fischer",
    items: "Desk lamp",
    status: "Shipped",
    total: "$89.00",
    paid: true,
  },
];
const statuses = Object.fromEntries(orders.map((o) => [o.id, o.status]));

const numberCls = css({
  color: "neutral.text.muted",
  fontVariantNumeric: "tabular-nums",
  fontWeight: "normal",
});
const itemsCls = css({ truncate: true, fontWeight: "normal" });
const totalCls = css({
  justifyContent: "flex-end",
  fontVariantNumeric: "tabular-nums",
  whiteSpace: "nowrap",
});
const logCls = css({
  margin: "0",
  textStyle: "body.sm",
  color: "neutral.text.muted",
});
---

<div
  class={stack({ gap: "sm", width: "100%" })}
  data-demo-grid-list-groups
  data-statuses={JSON.stringify(statuses)}
>
  <GridList
    label="Orders"
    items={orders}
    getId={(order: Order) => order.id}
    groupOf={(order: Order) => order.status}
    strong={(order: Order) => !order.paid}
    selectable
    groupSelect
    columns={[
      { id: "customer", track: "minmax(10rem, 14rem)" },
      { id: "number", track: "auto" },
      { id: "items", track: "minmax(0, 1fr)" },
      { id: "total", track: "auto" },
    ]}
  >
    {(ctx: GridListRender<Order>) =>
      ctx.region === "row" ? (
        <Fragment>
          <span {...ctx.cell("customer")}>
            <a {...ctx.primaryProps} href={`#${ctx.item.id}`}>
              {ctx.item.customer}
            </a>
          </span>
          <span {...ctx.cell("number", { class: numberCls })}>
            {ctx.item.number}
          </span>
          <span {...ctx.cell("items", { class: itemsCls })}>
            {ctx.item.items}
          </span>
          <span {...ctx.cell("total", { class: totalCls })}>
            {ctx.item.total}
          </span>
        </Fragment>
      ) : ctx.region === "groupLeading" ? (
        <Stamp size="xs" emphasis="ghost">
          <Package size={14} />
        </Stamp>
      ) : (
        <Button size="xs" emphasis="ghost" data-demo-print={ctx.group.name}>
          Print labels
        </Button>
      )
    }
  </GridList>
  <p class={logCls} aria-live="polite" data-demo-log>
    Select orders, then print their labels from a section title.
  </p>
</div>

<script>
  import {
    getGridList,
    gridListHydration,
  } from "@pindoba/astro-grid-list/runtime";

  for (const scope of document.querySelectorAll<HTMLElement>(
    "[data-demo-grid-list-groups]",
  )) {
    gridListHydration.boot(scope);
    const statuses = new Map<string, string>(
      Object.entries(JSON.parse(scope.dataset.statuses ?? "{}")),
    );
    const root = scope.querySelector("[data-grid-list-config]");
    const log = scope.querySelector("[data-demo-log]");
    scope.addEventListener("click", (event) => {
      const button =
        event.target instanceof Element
          ? event.target.closest<HTMLElement>("[data-demo-print]")
          : null;
      const group = button?.dataset.demoPrint;
      if (!group || !log) return;
      const selected = getGridList(root)?.value() ?? new Set();
      const count = [...selected].filter(
        (id) => statuses.get(id) === group,
      ).length;
      log.textContent = count
        ? `Printing ${count} label(s) for ${group}`
        : `Select orders in ${group} first`;
    });
  }
</script>

Looks and densities

look sets how rows read:

  • "plain" (the default) uses bare rows with a rounded hover and selection.
  • "divided" draws a rule between rows. Each group’s last row has none, because the next title band separates it.
  • "cards" makes every row its own rounded tile, as on a board.
  • "inset" is a grouped settings list. Each group is a rounded card with its title above it, and the rules between rows start at the second column, past a leading icon. insetStart names another column to start them from.

density sets the row height: "compact" (36px), "default" (44px) or "relaxed" (48px). Both are attributes on the root (data-grid-list-look, data-grid-list-density), not recipe variants, so every list shares one stylesheet.

// GENERATED from packages/demos/grid-list/looks.demo.tsx — edit the source, not this file.
import { useState } from "react";
import { GridList } from "@pindoba/react-grid-list";
import { Choice, ChoiceItem } from "@pindoba/react-choice";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

interface Task {
  id: string;
  title: string;
  assignee: string;
  due: string;
}

const tasks = [
  { id: "t1", title: "Write the release notes", assignee: "Inês", due: "Mon" },
  { id: "t2", title: "Review the pricing page", assignee: "Kofi", due: "Tue" },
  { id: "t3", title: "Fix the export timeout", assignee: "Yuna", due: "Wed" },
  { id: "t4", title: "Plan the offsite", assignee: "Tomás", due: "Fri" },
];
const toolbarCls = css({
  display: "flex",
  flexWrap: "wrap",
  gap: "sm",
});
const mutedCls = css({ color: "neutral.text.muted" });
const dueCls = css({
  justifyContent: "flex-end",
  color: "neutral.text.muted",
  whiteSpace: "nowrap",
});
export default function LooksDemo() {
  const [look, setLook] = useState(["plain"] as string[]);
  const [density, setDensity] = useState(["default"] as string[]);

  function lookOf(value: string | undefined) {
    return value === "divided" || value === "cards" ? value : "plain";
  }

  function densityOf(value: string | undefined) {
    return value === "compact" || value === "relaxed" ? value : "default";
  }

  return (
    <div className={stack({ gap: "sm", width: "100%" })}>
      <div className={toolbarCls}>
        <Choice
          type="radio"
          appearance="button"
          size="sm"
          aria-label="Look"
          value={look}
          onValueChange={setLook}
        >
          <ChoiceItem value="plain" label="Plain" />
          <ChoiceItem value="divided" label="Divided" />
          <ChoiceItem value="cards" label="Cards" />
        </Choice>
        <Choice
          type="radio"
          appearance="button"
          size="sm"
          aria-label="Density"
          value={density}
          onValueChange={setDensity}
        >
          <ChoiceItem value="compact" label="Compact" />
          <ChoiceItem value="default" label="Default" />
          <ChoiceItem value="relaxed" label="Relaxed" />
        </Choice>
      </div>
      <GridList
        label="Tasks"
        items={tasks}
        getId={(task: Task) => task.id}
        selectable
        look={lookOf(look[0])}
        density={densityOf(density[0])}
        columns={[
          { id: "title", track: "minmax(0, 1fr)" },
          { id: "assignee", track: "auto" },
          { id: "due", track: "auto" },
        ]}
        row={(task, ctx) => (
          <>
            <span {...ctx.cell("title")}>
              <a {...ctx.primaryProps} href={`#${task.id}`}>
                {task.title}
              </a>
            </span>
            <span {...ctx.cell("assignee", { class: mutedCls })}>
              {task.assignee}
            </span>
            <span {...ctx.cell("due", { class: dueCls })}>{task.due}</span>
          </>
        )}
      />
    </div>
  );
}

The inset look, on a settings list with an icon column, a value and a chevron. The chevron is plain row content, not part of the look:

Each group is a card on the surface.

---
// Hand-written: `packages/demos/grid-list/inset.demo.tsx` skips Astro (see
// its `skip`) — Astro GridList takes its rows through one default-slot
// callback, not a `row` render prop.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import Stamp from "@pindoba/astro-stamp";
import {
  Bell,
  Bluetooth,
  ChevronRight,
  Lock,
  Moon,
  Shield,
  Wifi,
} from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

interface Setting {
  id: string;
  label: string;
  value: string;
  icon: typeof Wifi;
  tone: "primary" | "success" | "danger" | "neutral";
  group: string;
}

// Items arrive sorted by group: each group is one inset card.
const settings: Setting[] = [
  {
    id: "wifi",
    label: "Wi-Fi",
    value: "Studio 5G",
    icon: Wifi,
    tone: "primary",
    group: "Connections",
  },
  {
    id: "bluetooth",
    label: "Bluetooth",
    value: "On",
    icon: Bluetooth,
    tone: "primary",
    group: "Connections",
  },
  {
    id: "notifications",
    label: "Notifications",
    value: "",
    icon: Bell,
    tone: "danger",
    group: "Alerts",
  },
  {
    id: "focus",
    label: "Focus",
    value: "Work",
    icon: Moon,
    tone: "neutral",
    group: "Alerts",
  },
  {
    id: "passcode",
    label: "Passcode",
    value: "",
    icon: Lock,
    tone: "success",
    group: "Security",
  },
  {
    id: "privacy",
    label: "Privacy",
    value: "",
    icon: Shield,
    tone: "primary",
    group: "Security",
  },
];
const labels = Object.fromEntries(settings.map((s) => [s.id, s.label]));

const valueCls = css({
  justifyContent: "flex-end",
  color: "neutral.text.muted",
  whiteSpace: "nowrap",
});
const chevronCls = css({ color: "neutral.text.muted" });
const logCls = css({
  margin: "0",
  textStyle: "body.sm",
  color: "neutral.text.muted",
});
---

<div
  class={stack({ gap: "sm", width: "100%" })}
  data-demo-grid-list-settings
  data-labels={JSON.stringify(labels)}
>
  <GridList
    label="Settings"
    look="inset"
    items={settings}
    getId={(setting: Setting) => setting.id}
    groupOf={(setting: Setting) => setting.group}
    columns={[
      { id: "icon", track: "auto" },
      { id: "label", track: "minmax(0, 1fr)" },
      { id: "value", track: "auto" },
      { id: "chevron", track: "auto" },
    ]}
  >
    {(ctx: GridListRender<Setting>) =>
      ctx.region === "row" ? (
        <Fragment>
          <span {...ctx.cell("icon")}>
            <Stamp size="sm" emphasis="primary" tone={ctx.item.tone}>
              <ctx.item.icon size={14} />
            </Stamp>
          </span>
          <span {...ctx.cell("label")}>
            <a {...ctx.primaryProps} href={`#${ctx.item.id}`}>
              {ctx.item.label}
            </a>
          </span>
          <span {...ctx.cell("value", { class: valueCls })}>
            {ctx.item.value}
          </span>
          <span {...ctx.cell("chevron", { class: chevronCls })}>
            <ChevronRight size={16} />
          </span>
        </Fragment>
      ) : null
    }
  </GridList>
  <p class={logCls} aria-live="polite" data-demo-log>
    Each group is a card on the surface.
  </p>
</div>

<script>
  import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";

  for (const scope of document.querySelectorAll<HTMLElement>(
    "[data-demo-grid-list-settings]",
  )) {
    const labels = new Map<string, string>(
      Object.entries(JSON.parse(scope.dataset.labels ?? "{}")),
    );
    const log = scope.querySelector("[data-demo-log]");
    const opened = (id: string): void => {
      if (log) log.textContent = `Opened ${labels.get(id) ?? id}`;
    };
    // The primary link is a demo anchor: open in place instead.
    scope.addEventListener("click", (event) => {
      const link =
        event.target instanceof Element
          ? event.target.closest<HTMLAnchorElement>("a[href^='#']")
          : null;
      if (!link) return;
      event.preventDefault();
      opened(link.hash.slice(1));
    });
    scope.addEventListener(GRID_LIST_EVENTS.open, (event) => {
      if (event instanceof CustomEvent && typeof event.detail?.id === "string")
        opened(event.detail.id);
    });
  }
</script>

rowBackground sets the resting fill of a cards or inset row, on Panel’s surface ladder (surface.peak … surface.ground, surface.base by default). Hover, the current row, selection and strong rows still paint over it. The other looks have no resting fill.

In a card

card renders the list inside its own Card. It takes the Card’s surface props, plus a heading, a subheading, header actions (trailing) and a footer. Without it, the list is bare. The rows pad to the card’s row inset, one step further in than the card’s own padding, and the card’s header and footer text line up with them. divided rows run from edge to edge, like a table’s. The rounded looks keep their fills a gutter inside the card’s edge.

Without a label, the card heading names the grid. A framed list that is the only content of another Card blends into it, as a Table does.

This week

4 open tasks

// GENERATED from packages/demos/grid-list/card.demo.tsx — edit the source, not this file.
import { useState } from "react";
import { GridList } from "@pindoba/react-grid-list";
import { Button } from "@pindoba/react-button";
import { Choice, ChoiceItem } from "@pindoba/react-choice";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

interface Task {
  id: string;
  title: string;
  assignee: string;
  due: string;
}

const tasks = [
  { id: "t1", title: "Write the release notes", assignee: "Inês", due: "Mon" },
  { id: "t2", title: "Review the pricing page", assignee: "Kofi", due: "Tue" },
  { id: "t3", title: "Fix the export timeout", assignee: "Yuna", due: "Wed" },
  { id: "t4", title: "Plan the offsite", assignee: "Tomás", due: "Fri" },
];
const mutedCls = css({ color: "neutral.text.muted" });
const dueCls = css({
  justifyContent: "flex-end",
  color: "neutral.text.muted",
  whiteSpace: "nowrap",
});
export default function CardDemo() {
  const [look, setLook] = useState(["divided"] as string[]);

  function lookOf(value: string | undefined) {
    return value === "plain" || value === "cards" ? value : "divided";
  }

  const cardTrailing0 = (
    <Button size="sm" emphasis="secondary">
      New task
    </Button>
  );
  const cardTrailing1 = (
    <Button size="sm" emphasis="ghost">
      View all tasks
    </Button>
  );

  return (
    <div className={stack({ gap: "sm", width: "100%" })}>
      <Choice
        type="radio"
        appearance="button"
        size="sm"
        aria-label="Look"
        value={look}
        onValueChange={setLook}
      >
        <ChoiceItem value="divided" label="Divided" />
        <ChoiceItem value="plain" label="Plain" />
        <ChoiceItem value="cards" label="Cards" />
      </Choice>
      <GridList
        items={tasks}
        getId={(task: Task) => task.id}
        selectable
        header
        look={lookOf(look[0])}
        card={{
          heading: "This week",
          subheading: "4 open tasks",
          trailing: cardTrailing0,
          footer: {
            trailing: cardTrailing1,
          },
        }}
        columns={[
          { id: "title", track: "minmax(0, 1fr)", header: "Task" },
          { id: "assignee", track: "auto", header: "Assignee" },
          { id: "due", track: "auto", header: "Due" },
        ]}
        row={(task, ctx) => (
          <>
            <span {...ctx.cell("title")}>
              <a {...ctx.primaryProps} href={`#${task.id}`}>
                {task.title}
              </a>
            </span>
            <span {...ctx.cell("assignee", { class: mutedCls })}>
              {task.assignee}
            </span>
            <span {...ctx.cell("due", { class: dueCls })}>{task.due}</span>
          </>
        )}
      />
    </div>
  );
}

Grid layout

layout="grid" lays the items out as tiles that wrap, as many per line as fit tileSize (default 16rem). Each tile is one gridcell holding the row render prop’s markup. A grid has no column template, header or fold, and its tiles take the plain and cards looks (the others draw as plain). Groups render as full-width titles over each block of tiles, and the checkbox sits over the tile’s corner. Without a header, put select-all under the tiles with selectAll="footer". ← / → move to the previous / next tile, and ↑ / ↓ move to the nearest tile in the line above or below, measured on screen, so wraps and group titles in between work. Drag and drop works in two dimensions: the marker stands upright between two tiles.

Hover a tile for its checkbox; click a picture to open it.

---
// Hand-written: `packages/demos/grid-list/gallery.demo.tsx` skips Astro (see
// its `skip`) — Astro GridList takes its tiles through one default-slot
// callback, not a `row` render prop.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

interface Picture {
  id: string;
  title: string;
  meta: string;
  seed: string;
  collection: string;
}

// Items arrive sorted by collection: each run is a section under its title.
const pictures: Picture[] = [
  {
    id: "p1",
    title: "Morning fog",
    meta: "Sintra · 4032 × 3024",
    seed: "pindoba-fog",
    collection: "Landscapes",
  },
  {
    id: "p2",
    title: "Salt flats",
    meta: "Uyuni · 6000 × 4000",
    seed: "pindoba-salt",
    collection: "Landscapes",
  },
  {
    id: "p3",
    title: "Pine ridge",
    meta: "Gerês · 4032 × 3024",
    seed: "pindoba-pine",
    collection: "Landscapes",
  },
  {
    id: "p4",
    title: "Low tide",
    meta: "Nazaré · 5472 × 3648",
    seed: "pindoba-tide",
    collection: "Landscapes",
  },
  {
    id: "p5",
    title: "Tram 28",
    meta: "Lisbon · 4032 × 3024",
    seed: "pindoba-tram",
    collection: "Streets",
  },
  {
    id: "p6",
    title: "Night market",
    meta: "Taipei · 6000 × 4000",
    seed: "pindoba-market",
    collection: "Streets",
  },
  {
    id: "p7",
    title: "Rooftops",
    meta: "Porto · 5472 × 3648",
    seed: "pindoba-roofs",
    collection: "Streets",
  },
];
const titles = Object.fromEntries(pictures.map((p) => [p.id, p.title]));

const imageCls = css({
  display: "block",
  width: "100%",
  aspectRatio: "4 / 3",
  objectFit: "cover",
  borderRadius: "sm",
  background: "neutral.surface.valley",
});
const titleCls = css({ fontWeight: "medium", truncate: true });
const metaCls = css({
  color: "neutral.text.muted",
  textStyle: "caption",
  fontVariantNumeric: "tabular-nums",
  truncate: true,
});
const logCls = css({
  margin: "0",
  textStyle: "body.sm",
  color: "neutral.text.muted",
});
---

<div
  class={stack({ gap: "sm", width: "100%" })}
  data-demo-grid-list-gallery
  data-titles={JSON.stringify(titles)}
>
  <GridList
    label="Gallery"
    layout="grid"
    tileSize="18rem"
    items={pictures}
    getId={(picture: Picture) => picture.id}
    groupOf={(picture: Picture) => picture.collection}
    selectable
    selectAll="footer"
  >
    {(ctx: GridListRender<Picture>) =>
      ctx.region === "row" ? (
        <Fragment>
          <a
            {...ctx.primaryProps}
            href={`#${ctx.item.id}`}
            aria-label={ctx.item.title}
          />
          <img
            class={imageCls}
            src={`https://picsum.photos/seed/${ctx.item.seed}/400/300`}
            alt=""
            loading="lazy"
            width="400"
            height="300"
          />
          <span class={titleCls}>{ctx.item.title}</span>
          <span class={metaCls}>{ctx.item.meta}</span>
        </Fragment>
      ) : null
    }
  </GridList>
  <p class={logCls} aria-live="polite" data-demo-log>
    Hover a tile for its checkbox; click a picture to open it.
  </p>
</div>

<script>
  import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";

  for (const scope of document.querySelectorAll<HTMLElement>(
    "[data-demo-grid-list-gallery]",
  )) {
    const titles = new Map<string, string>(
      Object.entries(JSON.parse(scope.dataset.titles ?? "{}")),
    );
    const log = scope.querySelector("[data-demo-log]");
    const opened = (id: string): void => {
      if (log) log.textContent = `Opened ${titles.get(id) ?? id}`;
    };
    // The primary link is a demo anchor: open in place instead.
    scope.addEventListener("click", (event) => {
      const link =
        event.target instanceof Element
          ? event.target.closest<HTMLAnchorElement>("a[href^='#']")
          : null;
      if (!link) return;
      event.preventDefault();
      opened(link.hash.slice(1));
    });
    scope.addEventListener(GRID_LIST_EVENTS.open, (event) => {
      if (event instanceof CustomEvent && typeof event.detail?.id === "string")
        opened(event.detail.id);
    });
  }
</script>

Replace selection

selectionBehavior="replace" is the photo-library model. A click selects only that item, ⌘ / Ctrl-click toggles it, and Shift-click selects the range from the anchor. The arrows move the selection with the focus (Shift extends it, ⌘ / Ctrl moves the focus alone). There are no checkboxes. A selected tile shows a check badge instead (in the rows layout, only the selected fill shows). On touch, a tap or a 500ms long press switches the list to toggling until the selection is empty; every tile then shows an empty ring.

Click a photo; ⌘/Ctrl-click or Shift-click for more.
---
// Hand-written: `packages/demos/grid-list/photo-library.demo.tsx` skips
// Astro (see its `skip`) — Astro GridList takes its tiles through one
// default-slot callback, not a `row` render prop. The script below follows
// the selection (`grid-list:valuechange`) and clears it through the handle.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import Button from "@pindoba/astro-button";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

interface Photo {
  id: string;
  seed: string;
  day: string;
  taken: string;
}

const days = ["Today", "Yesterday", "Monday"];

// Items arrive sorted by day: each run is a section under its title.
const library: Photo[] = Array.from({ length: 18 }, (_, i) => ({
  id: `photo-${i + 1}`,
  seed: `pindoba-photo-${i + 1}`,
  day: days[Math.floor(i / 6)] ?? "Monday",
  taken: `${days[Math.floor(i / 6)] ?? "Monday"}, photo ${(i % 6) + 1}`,
}));

const imageCls = css({
  display: "block",
  width: "100%",
  aspectRatio: "1",
  objectFit: "cover",
  borderRadius: "xs",
  background: "neutral.surface.valley",
});
const toolbarCls = css({
  display: "flex",
  alignItems: "center",
  gap: "sm",
  textStyle: "body.sm",
  color: "neutral.text.muted",
});
---

<div class={stack({ gap: "sm", width: "100%" })} data-demo-grid-list-photos>
  <div class={toolbarCls}>
    <Button size="sm" emphasis="secondary" disabled data-demo-clear>
      Clear
    </Button>
    <span aria-live="polite" data-demo-log>
      Click a photo; ⌘/Ctrl-click or Shift-click for more.
    </span>
  </div>
  <GridList
    label="Photos"
    layout="grid"
    tileSize="9rem"
    items={library}
    getId={(photo: Photo) => photo.id}
    groupOf={(photo: Photo) => photo.day}
    selectable
    selectionBehavior="replace"
  >
    {(ctx: GridListRender<Photo>) =>
      ctx.region === "row" ? (
        <img
          class={imageCls}
          src={`https://picsum.photos/seed/${ctx.item.seed}/200/200`}
          alt={ctx.item.taken}
          loading="lazy"
          draggable="false"
          width="200"
          height="200"
        />
      ) : null
    }
  </GridList>
</div>

<script>
  import {
    GRID_LIST_EVENTS,
    getGridList,
    gridListHydration,
  } from "@pindoba/astro-grid-list/runtime";

  for (const scope of document.querySelectorAll<HTMLElement>(
    "[data-demo-grid-list-photos]",
  )) {
    gridListHydration.boot(scope);
    const root = scope.querySelector("[data-grid-list-config]");
    const log = scope.querySelector("[data-demo-log]");
    const clear = scope.querySelector<HTMLButtonElement>("[data-demo-clear]");
    const render = (count: number): void => {
      if (clear) clear.disabled = count === 0;
      if (log)
        log.textContent =
          count === 0
            ? "Click a photo; ⌘/Ctrl-click or Shift-click for more."
            : `${count} selected`;
    };
    scope.addEventListener(GRID_LIST_EVENTS.valueChange, () =>
      render(getGridList(root)?.value().size ?? 0),
    );
    clear?.addEventListener("click", () => {
      getGridList(root)?.setValue([]);
      render(0);
    });
  }
</script>

Pagination

Paging is opt-in: set pageSize and the list shows one page at a time, with a Pagination under it. page is zero-based; bind it (bind:page) or follow onPageChange(page). pageSizeOptions adds the rows-per-page select (without it there is none): picking a size calls onPageSizeChange(size) and goes back to the first page. labels.pagination localizes the pager. Without pageSize, every row shows, as before.

Page 1 · 5 per page

---
// Hand-written: `packages/demos/grid-list/pagination.demo.tsx` skips Astro
// (see its `skip`) — Astro GridList takes its rows through one default-slot
// callback, not a `row` render prop. Every page renders on the server; the
// runtime pages on the client and the script below follows its events.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

interface Task {
  id: string;
  title: string;
  owner: string;
  due: string;
}

const titles = [
  "Draft the release notes",
  "Review the onboarding copy",
  "Fix the export timeout",
  "Plan the Q4 roadmap review",
  "Update the billing FAQ",
  "Audit unused feature flags",
  "Write the migration guide",
  "Triage the support backlog",
  "Refresh the pricing page",
  "Rotate the staging keys",
  "Prepare the board summary",
  "Test the new invoice layout",
  "Book the team offsite",
  "Archive last year's projects",
  "Tune the search ranking",
  "Collect beta feedback",
  "Rename the legacy endpoints",
  "Sketch the empty states",
  "Measure the cold start",
  "Clean up the design tokens",
  "Interview two candidates",
  "Ship the dark mode fixes",
  "Close the stale pull requests",
];
const owners = ["Inês", "Kofi", "Yuna", "Tomás", "Amara"];

const tasks: Task[] = titles.map((title, i) => ({
  id: `t${i + 1}`,
  title,
  owner: owners[i % owners.length] ?? "",
  due: `Nov ${(i % 28) + 1}`,
}));

const titleCls = css({ truncate: true });
const mutedCls = css({
  color: "neutral.text.muted",
  textStyle: "caption",
  whiteSpace: "nowrap",
});
const logCls = css({
  margin: "0",
  textStyle: "body.sm",
  color: "neutral.text.muted",
});
---

<div class={stack({ gap: "sm", width: "100%" })} data-demo-grid-list-pages>
  <GridList
    label="Tasks"
    items={tasks}
    getId={(task: Task) => task.id}
    selectable
    header
    page={0}
    pageSize={5}
    pageSizeOptions={[5, 10, 20]}
    columns={[
      { id: "title", track: "minmax(0, 1fr)", header: "Task" },
      { id: "owner", track: "minmax(6rem, 10rem)", header: "Owner" },
      { id: "due", track: "auto", header: "Due" },
    ]}
  >
    {(ctx: GridListRender<Task>) =>
      ctx.region === "row" ? (
        <Fragment>
          <span {...ctx.cell("title", { class: titleCls })}>
            {ctx.item.title}
          </span>
          <span {...ctx.cell("owner")}>{ctx.item.owner}</span>
          <span {...ctx.cell("due", { class: mutedCls })}>{ctx.item.due}</span>
        </Fragment>
      ) : null
    }
  </GridList>
  <p class={logCls} aria-live="polite" data-demo-log>
    Page 1 · 5 per page
  </p>
</div>

<script>
  import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";

  for (const scope of document.querySelectorAll<HTMLElement>(
    "[data-demo-grid-list-pages]",
  )) {
    const log = scope.querySelector("[data-demo-log]");
    let page = 0;
    let pageSize = 5;
    const render = (): void => {
      if (log) log.textContent = `Page ${page + 1} · ${pageSize} per page`;
    };
    scope.addEventListener(GRID_LIST_EVENTS.pageChange, (event) => {
      if (!(event instanceof CustomEvent)) return;
      if (typeof event.detail?.page === "number") page = event.detail.page;
      render();
    });
    scope.addEventListener(GRID_LIST_EVENTS.pageSizeChange, (event) => {
      if (!(event instanceof CustomEvent)) return;
      if (typeof event.detail?.pageSize === "number")
        pageSize = event.detail.pageSize;
      render();
    });
  }
</script>
  • Client paging (the default). items is the whole collection and the list slices it to the page. If the items shrink past the page (a delete, a row dragged out), the list moves to the last page and reports it through onPageChange.
  • Server paging. Pass itemsTotal, and items is the current page already: the list doesn’t slice it, and the total drives the page count. Fetch the next page from onPageChange.
  • Groups are worked out per page. A group split across two pages shows its title on each.
  • Selection stays keyed by id across pages, and select-all follows Table’s rule. With client paging the list holds every item, so the header checkbox (and ⌘A) covers every item on every page, collapsed groups included, and its indeterminate state reflects the whole list. With server paging (itemsTotal) the items on other pages aren’t loaded, so it covers only the current page (“Select all on this page”) and leaves the rest of the selection alone. Esc clears the whole selection.
  • Focus. After a page change the roving tabindex moves to the page’s first row. Focus moves there too only when a row had it (a pager click keeps its own focus).
  • Drag positions. On a paged list, onMove’s from.index and to.index are positions in the full list (the page’s index plus page * pageSize), so the consumer moves the item in its whole array. Rows move within the page; reaching another page takes the pager. The offset comes from dropZone(list, { indexOffset }) in @pindoba/core-drag.

Scrolling

A list in a fixed-height region (a dialog body, a side panel) scrolls best on its own: scroll makes the list fill a flex column (flex: 1, min-height: 0), and its header and rows scroll inside it. The column header and group titles stick to the top of the scroll area, and a paged list’s pager sits under it. Padding around the list, its own padding (on Panel’s spacing scale, xs below; paddingX / paddingY set one axis) or its container’s, stays outside the scroll area, so rows never scroll through it. It is all CSS, so the server render matches.

  • --gl-scroll-gap (xs by default) keeps the rows off the scrollbar, and only while the list actually scrolls: a list that fits keeps its full width. Overlay scrollbars float over the content and reserve no gutter. The switch is CSS (a scroll-driven animation on the list’s own scroll timeline); engines without scroll-driven animations show no gap.
  • --gl-scroll-end-inset adds scrolled room under the last row, for something floating over the list (a BulkBar): the rows scroll out from under it.

A list of 30 tasks in a 360px-tall region:

---
// Hand-written: `packages/demos/grid-list/scroll.demo.tsx` skips Astro (see
// its `skip`) — Astro GridList takes its rows through one default-slot
// callback, not a `row` render prop. `scroll` is CSS only, so the server
// render scrolls the same way.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { css } from "@pindoba/styled-system/css";

interface Task {
  id: string;
  title: string;
  owner: string;
  due: string;
  status: string;
}

const owners = ["Inês Duarte", "Kofi Mensah", "Yuna Choi", "Tomás Rivera"];
const titles = [
  "Draft the release notes",
  "Review the onboarding copy",
  "Fix the export timeout",
  "Plan the Q3 roadmap",
  "Audit unused feature flags",
  "Update the support macros",
];
const statuses = ["In progress", "To do", "Done"];

// 30 tasks, sorted by status: each status is a sticky group.
const tasks: Task[] = Array.from({ length: 30 }, (_, i) => ({
  id: `t${i}`,
  title: titles[i % titles.length]!,
  owner: owners[i % owners.length]!,
  due: `Nov ${(i % 28) + 1}`,
  status: statuses[Math.floor(i / 10)]!,
}));

// A fixed-height region (a side panel, a dialog body): a flex column the
// list fills.
const regionCls = css({
  display: "flex",
  flexDirection: "column",
  width: "100%",
  height: "360px",
});
const ownerCls = css({ color: "neutral.text.muted", truncate: true });
const dueCls = css({
  justifyContent: "flex-end",
  color: "neutral.text.muted",
  textStyle: "caption",
  whiteSpace: "nowrap",
});
---

<div class={regionCls}>
  <GridList
    label="Tasks"
    items={tasks}
    getId={(task: Task) => task.id}
    groupOf={(task: Task) => task.status}
    selectable
    header
    scroll
    padding="xs"
    columns={[
      { id: "title", track: "minmax(0, 1fr)", header: "Task" },
      { id: "owner", track: "minmax(8rem, auto)", header: "Owner" },
      { id: "due", track: "auto", header: "Due" },
    ]}
  >
    {(ctx: GridListRender<Task>) =>
      ctx.region === "row" ? (
        <Fragment>
          <span {...ctx.cell("title")}>{ctx.item.title}</span>
          <span {...ctx.cell("owner", { class: ownerCls })}>
            {ctx.item.owner}
          </span>
          <span {...ctx.cell("due", { class: dueCls })}>{ctx.item.due}</span>
        </Fragment>
      ) : null
    }
  </GridList>
</div>

Virtual rows

virtual renders only the rows in view, plus a few past each end, so a list of thousands of rows stays light. Rows are measured as they render, so they can differ in height: a card with a second line, a row that wraps. Spacers stand in for the rows that aren’t rendered.

  • The scroll port. virtual on its own scrolls the list’s own grid, so give the grid a height (passThrough.grid.style, a maxHeight, as below). To scroll with an element around the list (a dialog body, a scroll well), pass it: virtual={{ scrollElement: () => wellElement }}. The sticky column header and group titles stick inside the port either way.
  • estimateSize is a row’s height before it is measured: a number, or one per item ((item) => …). It defaults to the density’s row height (36 / 44 / 48px). It only needs to be close. overscan sets how many rows render past each end (default 6).
  • Keyboard and selection work on the full list, not the rendered rows. ↑ / ↓, Home / End move to rows that aren’t rendered yet: the list renders the target and focuses it, which scrolls it into view. The focused row always stays rendered, so the list’s Tab stop never disappears. Shift ranges, ⌘A, select-all and disabledIds cover every item.
  • Groups. Group titles are windowed like rows. The title of the group in view always renders, so it keeps sticking.
  • With pagination, the window runs over the page, and a page change scrolls back to the top.
  • What’s on screen. virtual={{ onVisibleRangeChange }} reports the rows on screen as they change, overscan left out: startIndex and endIndex into items, and the rows’ ids. Use it for work worth doing only for what the user sees, such as fetching per-row details.
  • Accessibility. The grid carries aria-rowcount (every row, the header and group titles included) and each rendered row its aria-rowindex, so screen readers announce the full size.
// GENERATED from packages/demos/grid-list/virtual.demo.tsx — edit the source, not this file.
import { GridList } from "@pindoba/react-grid-list";
import { css } from "@pindoba/styled-system/css";

interface Commit {
  id: string;
  hash: string;
  message: string;
  body: string;
  author: string;
  day: string;
}

const authors = ["Inês Duarte", "Kofi Mensah", "Yuna Choi", "Tomás Rivera"];
const subjects = [
  "Fix the empty state's spacing",
  "Add keyboard shortcuts to the toolbar",
  "Bump dependencies",
  "Rename the export button",
  "Cache the search index",
];
const commits = Array.from({ length: 1000 }, (_, i) => ({
  id: `c${i}`,
  hash: (0x9f3a2c + i * 7919).toString(16).slice(0, 7),
  message: `${subjects[i % subjects.length]} (#${4000 - i})`,
  body: i % 3 === 0 ? "Also updates the snapshots and the changelog." : "",
  author: authors[i % authors.length]!,
  day: `Day ${40 - Math.floor(i / 25)}`,
}));
const gridCls = css.raw({ maxHeight: "480px" });
const textCls = css({
  display: "flex",
  flexDirection: "column",
  minWidth: "0",
});
const bodyCls = css({ color: "neutral.text.muted", truncate: true });
const metaCls = css({
  justifyContent: "flex-end",
  color: "neutral.text.muted",
  textStyle: "caption",
  fontFamily: "mono",
  whiteSpace: "nowrap",
});
export default function VirtualDemo() {
  return (
    <GridList
      label="Commits"
      items={commits}
      getId={(commit: Commit) => commit.id}
      groupOf={(commit: Commit) => commit.day}
      selectable
      header
      virtual
      passThrough={{ grid: { style: gridCls } }}
      columns={[
        { id: "message", track: "minmax(0, 1fr)", header: "Commit" },
        { id: "author", track: "minmax(8rem, auto)", header: "Author" },
        { id: "hash", track: "auto", srHeader: "Hash" },
      ]}
      row={(commit, ctx) => (
        <>
          <span {...ctx.cell("message")}>
            <span className={textCls}>
              <a {...ctx.primaryProps} href={`#${commit.id}`}>
                {commit.message}
              </a>
              {commit.body && <span className={bodyCls}>{commit.body}</span>}
            </span>
          </span>
          <span {...ctx.cell("author")}>{commit.author}</span>
          <span {...ctx.cell("hash", { class: metaCls })}>{commit.hash}</span>
        </>
      )}
    />
  );
}

Not supported yet (virtual is ignored and every row renders):

  • The grid layout (layout="grid"): tiles need lanes (several items per visual row).
  • Draggable lists (drag): a drop needs every row in the DOM to land between.
  • Astro: the server renders every row. A windowed runtime would have to park off-screen server markup the way the paged store does.
  • Infinite loading (fetching more as the end comes into view) is not built in. Grow items yourself; the window follows.

Drag and drop

Pass drag={{ context, list }} to make rows reorderable. context comes from createDragContext() in @pindoba/core-drag, and several lists that share one context move rows between them. A drop reports one onMove({ id, from, to }), with { list, index } for both ends, and your code moves the item. The list never reorders its own data.

import { createDragContext } from "@pindoba/core-drag";

const drag = createDragContext({
  type: "task",
  onMove: ({ id, from, to }) => moveTask(id, from, to),
  nameOf: (id) => titleOf(id), // used in announcements
  listName: (list) => listLabel[list],
});
$effect(() => () => drag.destroy()); // Svelte; React: a useEffect cleanup
// Hand-written: `packages/demos/grid-list/drag.demo.tsx` skips React (see its
// `skip`) — the drag context must be destroyed on unmount, which the demo
// vocabulary can't express. Two lists sharing one drag context: a drop
// (pointer, touch or Alt+arrows) reports one `{ id, from, to }`; the demo
// moves the task.
import { useEffect, useRef, useState } from "react";
import { Avatar } from "@pindoba/react-avatar";
import { GridList } from "@pindoba/react-grid-list";
import {
  createDragContext,
  type DragContext,
  type DragMove,
} from "@pindoba/core-drag";
import { css } from "@pindoba/styled-system/css";

interface Task {
  id: string;
  title: string;
  owner: string;
}

const lists = ["todo", "done"] as const;
type List = (typeof lists)[number];
const listLabel: Record<List, string> = { todo: "To do", done: "Done" };
const isList = (list: string): list is List =>
  (lists as readonly string[]).includes(list);

const initialBoard: Record<List, Task[]> = {
  todo: [
    { id: "t1", title: "Write the release notes", owner: "Inês Duarte" },
    { id: "t2", title: "Review the pricing page", owner: "Kofi Mensah" },
    { id: "t3", title: "Fix the export timeout", owner: "Yuna Choi" },
  ],
  // Starts short: a drop below the last row, or into an emptied list, still
  // lands.
  done: [{ id: "t4", title: "Plan the offsite", owner: "Tomás Rivera" }],
};

const boardCls = css({
  display: "grid",
  gridTemplateColumns: "repeat(auto-fit, minmax(24rem, 1fr))",
  gap: "sm",
  width: "100%",
});
const columnCls = css({
  display: "flex",
  flexDirection: "column",
  gap: "2xs",
  padding: "xs",
  borderRadius: "lg",
  background: "neutral.surface.base",
  minHeight: "20rem",
  // Sticky titles and the drag placeholder paint this surface.
  "--panel-bg": "{colors.neutral.surface.base}",
});
const headCls = css({
  paddingInline: "2xs",
  textStyle: "body.sm",
  fontWeight: "semibold",
  color: "neutral.text.bold",
});
// The list fills the column, so the whole column body takes drops.
const listCls = css({
  flex: "1",
  display: "flex",
  flexDirection: "column",
  "& > [data-component=grid-list]": {
    flex: "1",
    display: "flex",
    flexDirection: "column",
  },
  "& [data-grid-list-grid]": { flex: "1", alignContent: "start" },
});
const titleCls = css({ truncate: true });
const logCls = css({
  margin: "0",
  marginBlockStart: "xs",
  textStyle: "body.sm",
  color: "neutral.text.muted",
  minHeight: "2.4rem",
});

export default function Drag() {
  const [board, setBoard] = useState(initialBoard);
  const [log, setLog] = useState("");
  const [drag, setDrag] = useState<DragContext>();
  // The context's callbacks outlive renders: they read the latest board.
  const boardRef = useRef(board);
  boardRef.current = board;

  useEffect(() => {
    function onMove({ id, from, to }: DragMove) {
      if (!isList(from.list) || !isList(to.list)) return;
      const current = boardRef.current;
      const task = current[from.list].find((t) => t.id === id);
      if (!task) return;
      const source = current[from.list].filter((t) => t.id !== id);
      const target = from.list === to.list ? source : [...current[to.list]];
      target.splice(Math.min(to.index, target.length), 0, task);
      const next = { ...current, [from.list]: source, [to.list]: target };
      boardRef.current = next;
      setBoard(next);
      setLog(
        `“${task.title}” moved to ${listLabel[to.list]}, position ${to.index + 1}`,
      );
    }
    const context = createDragContext({
      type: "task",
      onMove,
      nameOf: (id) =>
        Object.values(boardRef.current)
          .flat()
          .find((t) => t.id === id)?.title ?? "task",
      listName: (list) => (isList(list) ? listLabel[list] : list),
    });
    setDrag(context);
    return () => context.destroy();
  }, []);

  return (
    <>
      <div className={boardCls}>
        {lists.map((list) => (
          <section
            key={list}
            className={columnCls}
            aria-label={listLabel[list]}
          >
            <div className={headCls}>
              {listLabel[list]} · {board[list].length}
            </div>
            <div className={listCls}>
              <GridList
                label={listLabel[list]}
                items={board[list]}
                getId={(t) => t.id}
                selectable
                selectOver="avatar"
                look="cards"
                density="compact"
                drag={drag ? { context: drag, list } : undefined}
                columns={[
                  { id: "avatar", track: "auto" },
                  { id: "title", track: "minmax(0, 1fr)" },
                ]}
                row={(task, { cell, primaryProps }) => (
                  <>
                    <span {...cell("avatar")}>
                      <Avatar name={task.owner} size="xs" />
                    </span>
                    <span {...cell("title")}>
                      <a
                        {...primaryProps}
                        className={titleCls}
                        href={`#${task.id}`}
                        onClick={(e) => {
                          e.preventDefault();
                          setLog(`Opened “${task.title}”`);
                        }}
                      >
                        {task.title}
                      </a>
                    </span>
                  </>
                )}
              />
            </div>
          </section>
        ))}
      </div>
      <p className={logCls} aria-live="polite">
        {log || " "}
      </p>
    </>
  );
}
  • Pointer and touch. A row drags from anywhere on it. There is no grip, and no handle column. The sensor waits for a few pixels of movement, so a click still opens the row and a checkbox click still selects. Real controls never start a drag. The stretched link carries data-drag-through, so it passes the drag on, and links and images lose their native drag in a draggable list.
  • The drop. An insertion line marks the slot under the pointer, and an empty list shows a well that still takes the drop. While a row is lifted, the list holds on to its column tracks, so the row keeps its shape. The row then glides into place, and the move commits once it lands.
  • Keyboard. On a focused row, Alt+↑ / Alt+↓ moves it one slot within its list, and Alt+← / Alt+→ moves it to the previous or next list, at the same row number. Focus follows the row. A live region announces the move, for example “Moved Plan the offsite to position 1 in Done.” (nameOf and listName supply the names) Rows advertise these keys with aria-keyshortcuts and describe them with aria-description.
  • Groups as drop lists. With groupOf, drag={{ context, list, groups: true }} makes each group a drop list of its own. A drop under another group’s title moves the item into that group: onMove reports the group as to.list (with the position within the group) and the list’s id as to.zone. Your code changes the item’s group. Keyboard moves cross into the next group at a group’s ends. With client paging, to.index also counts the group’s items on earlier pages.
  • Pointer announcements. The drag context’s live region also speaks pointer drags: “Picked up …”, “Moved … to position N in …” once the pointer rests on a new slot for half a second, and “Dropped …” or “Cancelled. …”.
  • Cost. @pindoba/core-drag is the only package that imports @dnd-kit/dom (about 36 KB gzip). Import it only in lists that enable drag. Call destroy() when the lists unmount: it removes the manager and the live region.

Groups as drop lists, on one list of orders grouped by status: drag an order under another status, or press Alt+↓ on a group’s last order.

// Hand-written: `packages/demos/grid-list/drag-groups.demo.tsx` skips React
// (see its `skip`) — the drag context must be destroyed on unmount, which the
// demo vocabulary can't express. One list grouped by status, each group a
// drop list (`drag.groups`): a drop under another group's title moves the
// order there, and Alt+↑/↓ crosses into the next group at a group's end.
// `to.list` is the target group and `to.index` the position within it.
import { useEffect, useRef, useState } from "react";
import { GridList } from "@pindoba/react-grid-list";
import {
  createDragContext,
  type DragContext,
  type DragMove,
} from "@pindoba/core-drag";
import { css } from "@pindoba/styled-system/css";

interface Order {
  id: string;
  number: string;
  customer: string;
  status: string;
}

const initialOrders: Order[] = [
  { id: "o1", number: "#3184", customer: "Inês Duarte", status: "To pack" },
  { id: "o2", number: "#3183", customer: "Kofi Mensah", status: "To pack" },
  { id: "o3", number: "#3181", customer: "Yuna Choi", status: "To pack" },
  {
    id: "o4",
    number: "#3179",
    customer: "Tomás Rivera",
    status: "Ready to ship",
  },
  {
    id: "o5",
    number: "#3176",
    customer: "Amara Okafor",
    status: "Ready to ship",
  },
  { id: "o6", number: "#3170", customer: "Lena Fischer", status: "Shipped" },
];

// Items stay sorted by group: the order lands before the target group's
// order at that position, else after its last.
function applyMove(orders: Order[], { id, to }: DragMove): Order[] {
  const order = orders.find((o) => o.id === id);
  if (!order) return orders;
  const rest = orders.filter((o) => o.id !== id);
  const members = rest.filter((o) => o.status === to.list);
  const before = members[to.index];
  const last = members.at(-1);
  const at = before
    ? rest.indexOf(before)
    : last
      ? rest.indexOf(last) + 1
      : orders.indexOf(order);
  rest.splice(at, 0, { ...order, status: to.list });
  return rest;
}

const numberCls = css({
  color: "neutral.text.muted",
  fontVariantNumeric: "tabular-nums",
});
const logCls = css({
  margin: "0",
  marginBlockStart: "xs",
  textStyle: "body.sm",
  color: "neutral.text.muted",
  minHeight: "2.4rem",
});

export default function DragGroups() {
  const [orders, setOrders] = useState(initialOrders);
  const [log, setLog] = useState("");
  const [drag, setDrag] = useState<DragContext>();
  // The context's callbacks outlive renders: they read the latest orders.
  const ordersRef = useRef(orders);
  ordersRef.current = orders;

  useEffect(() => {
    const context = createDragContext({
      type: "order",
      onMove: (move) => {
        const order = ordersRef.current.find((o) => o.id === move.id);
        if (!order) return;
        const next = applyMove(ordersRef.current, move);
        ordersRef.current = next;
        setOrders(next);
        setLog(
          `${order.number} moved to ${move.to.list}, position ${move.to.index + 1}`,
        );
      },
      nameOf: (id) =>
        ordersRef.current.find((o) => o.id === id)?.number ?? "order",
      listName: (list) => list,
    });
    setDrag(context);
    return () => context.destroy();
  }, []);

  return (
    <>
      <GridList
        label="Orders"
        items={orders}
        getId={(o) => o.id}
        groupOf={(o) => o.status}
        look="cards"
        density="compact"
        drag={
          drag ? { context: drag, list: "orders", groups: true } : undefined
        }
        columns={[
          { id: "customer", track: "minmax(0, 1fr)" },
          { id: "number", track: "auto" },
        ]}
        row={(order, { cell, primaryProps }) => (
          <>
            <span {...cell("customer")}>
              <a
                {...primaryProps}
                href={`#${order.id}`}
                onClick={(e) => {
                  e.preventDefault();
                  setLog(`Opened ${order.number}`);
                }}
              >
                {order.customer}
              </a>
            </span>
            <span {...cell("number", { class: numberCls })}>
              {order.number}
            </span>
          </>
        )}
      />
      <p className={logCls} aria-live="polite">
        {log || " "}
      </p>
    </>
  );
}

Accessibility

  • The list is a role="grid" named by label, with role="row" rows and role="gridcell" cells. Header cells are columnheaders, and a group’s title band is a rowheader with its chevron button (aria-expanded, named “Collapse Orders” / “Expand Orders”).
  • The rows are one tab stop with a roving tabindex. A selectable list sets aria-multiselectable on the grid and aria-selected on each row. A column header that is only an icon can take an srHeader label for screen readers.
  • Every control inside a row is out of the Tab order (tabindex="-1"), and → / F2 reach them (see Cell navigation). Controls outside the rows stay in the Tab order: the header’s select-all checkbox, the group titles’ chevrons and trailing actions, and the footer.
  • Row actions are row controls like any other: each icon button is named by its action’s label and announces its shortcut (aria-keyshortcuts), and the ⋯ button (“More actions”, labels.moreActions) opens a menu. The same menu opens from the keyboard with Shift+F10 or the ContextMenu key.
  • Every string the list writes (the select column, the select-all and row checkboxes, group toggles, row actions and drag instructions) comes from labels, for translation. Drag announcements come from the drag context’s own labels.
  • Give a selectable list rowName={(item) => …} so each row’s checkbox says which row it selects (“Select web”, from labels.selectRowNamed). Without it every checkbox is “Select row” (labels.selectRow), which screen readers can only tell apart by the row around it.

Framework notes

Every framework spreads the same connectGridList bags; only the way rows and two-way state reach the list differs.

FrameworkRow contentSelection, page, page size
Svelte{#snippet row(item, { cell, primaryProps, selected })}, groupLeading / groupTrailingbind:value, bind:page, bind:pageSize
Reactrow={(item, ctx) => …}, groupLeading / groupTrailing render propsvalue + onValueChange, page + onPageChange, pageSize + onPageSizeChange
Vue:row="(item, ctx) => h(…)", or the scoped slot #row="{ item, cell, primaryProps }"v-model:value (or onValueChange), v-model:page, v-model:pageSize
Solidrow={(item, ctx) => …} (the ctx fields update in place)value + onValueChange, page + onPageChange, pageSize + onPageSizeChange
AstroOne default-slot callback for rows and group titles, told apart by ctx.regionvalue / defaultValue render; then getGridList(el), grid-list:valuechange / pagechange

In React, Vue and Solid, ctx.cell() and primaryProps come back already normalized for the framework (className in React), so a row spreads them directly. Drag needs a createDragContext() per mount, destroyed on unmount (a useEffect cleanup, onBeforeUnmount, onSettled’s cleanup).

Astro renders every row on the server, including the rows of other pages, which wait in a <template> for client paging. The runtime (@pindoba/astro-grid-list/runtime) then runs selection, keyboard, groups and paging with the same core. Content is rendered once, so a group title’s selectedCount is the server’s. Read the live selection from getGridList(el)?.value() instead. Drag needs a context, which exists only in the browser: pass it with getGridList(el)?.update({ drag: { list, context } }) and apply each onMove with update({ items }). The drag demos have no Astro tab for that reason, and neither does the looks switcher, whose controls change the list’s structure props after load.

Panda with jsxFramework. Once a project’s panda.config sets jsxFramework, Panda reads the props of every capitalised component as style props, so it can follow styled() wrappers. columns is also a CSS property, so an inline columns={[…]} makes Panda warn (“columns in columns.base.id is not a condition or selector”) and emit a rule that does nothing. Set jsxStyleProps: "minimal" in that config: Panda then reads only the css prop. A Svelte project loses nothing by it, since Panda generates no style-prop components for Svelte.

  • Components
  • Blocks