component

Choice

Choice is a selection-state manager. It owns the logic of selection — single (type="radio") or multiple (type="checkbox") — and delegates the visual of each selectable item to the wrapped Radio/Checkbox (and, for the card appearance, an inner Card). Build segmented controls, selectable cards, or plain checkbox lists from one component by focusing on the action — making a choice — rather than the visual.

Each item is a <ChoiceItem> whose appearance picks the look: radio/checkbox (native indicator), button (segmented control), or card. Items coordinate through the parent automatically — selecting one radio deselects its siblings, and the group exposes the standard role="radiogroup" / role="group" semantics.

Default

A radio group rendered as a segmented control.

Selected: daily

---
// GENERATED from packages/demos/choice/default.demo.tsx — edit the source, not this file.
import Choice from "@pindoba/astro-choice";
import { ChoiceItem } from "@pindoba/astro-choice";

const value = ["daily"] as string[];
---

<Choice type="radio" data-ir-root="choice/default" data-ir="1">
  <ChoiceItem value="daily" label="Daily" type="radio" checked />
  <ChoiceItem value="weekly" label="Weekly" type="radio" />
  <ChoiceItem value="monthly" label="Monthly" type="radio" />
</Choice>

<p data-ir-root="choice/default">
  Selected: <!--ir:2-->
  {value.join(", ") || "none"}
  <!--/ir-->
</p>

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

  const roots = [
    ...document.querySelectorAll('[data-ir-root="choice/default"]'),
  ];
  for (let i = 0; i < roots.length; i += 2) hydrate(roots.slice(i, i + 2));
</script>

Segmented control

type="radio" with appearance="button". Use tone to color the active state. Seed the initial selection with defaultValue (uncontrolled) or bind value (controlled).

The selection is a single thumb that slides between segments, in either orientation. It’s pure CSS (anchor positioning), so it works the same in every framework, Astro included, and it’s skipped under reduced motion. Browsers without anchor positioning fall back to each segment filling itself. Pass thumb={false} to keep that static fill everywhere. The thumb is off for a merged group and for checkbox groups. It paints the selected segment’s tone/emphasis, which is the group’s unless that item sets its own.

---
// GENERATED from packages/demos/choice/segmented.demo.tsx — edit the source, not this file.
import Choice from "@pindoba/astro-choice";
import { ChoiceItem } from "@pindoba/astro-choice";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "lg", direction: "column" })}>
  <Choice type="radio" appearance="button" tone="primary">
    <ChoiceItem
      value="list"
      label="List"
      type="radio"
      appearance="button"
      tone="primary"
      checked
    />
    <ChoiceItem
      value="board"
      label="Board"
      type="radio"
      appearance="button"
      tone="primary"
    />
    <ChoiceItem
      value="calendar"
      label="Calendar"
      type="radio"
      appearance="button"
      tone="primary"
    />
  </Choice>

  <Choice type="radio" appearance="button" tone="success">
    <ChoiceItem
      value="s"
      label="Small"
      type="radio"
      appearance="button"
      tone="success"
    />
    <ChoiceItem
      value="m"
      label="Medium"
      type="radio"
      appearance="button"
      tone="success"
      checked
    />
    <ChoiceItem
      value="l"
      label="Large"
      type="radio"
      appearance="button"
      tone="success"
    />
  </Choice>

  <Choice type="radio" appearance="button" orientation="vertical">
    <ChoiceItem
      value="inbox"
      label="Inbox"
      type="radio"
      appearance="button"
      checked
    />
    <ChoiceItem
      value="drafts"
      label="Drafts"
      type="radio"
      appearance="button"
    />
    <ChoiceItem
      value="archive"
      label="Archive"
      type="radio"
      appearance="button"
    />
  </Choice>

  <Choice type="radio" appearance="button" thumb={false}>
    <ChoiceItem
      value="a"
      label="Static"
      type="radio"
      appearance="button"
      checked
    />
    <ChoiceItem value="b" label="Fill" type="radio" appearance="button" />
    <ChoiceItem value="c" label="Only" type="radio" appearance="button" />
  </Choice>
</div>

A segment with its own colorway

Set tone / emphasis on one ChoiceItem to set it apart. A destructive option, say: the thumb turns into a solid danger fill only while that segment is selected, and the rest of the track stays in the group’s colorway.

---
// GENERATED from packages/demos/choice/thumb-item-tone.demo.tsx — edit the source, not this file.
import Choice from "@pindoba/astro-choice";
import { ChoiceItem } from "@pindoba/astro-choice";
---

<Choice type="radio" appearance="button" size="sm" aria-label="Deletion method">
  <ChoiceItem
    value="trash"
    label="Move to Trash"
    type="radio"
    appearance="button"
    size="sm"
    checked
  />
  <ChoiceItem
    value="permanent"
    label="Delete permanently"
    tone="danger"
    emphasis="primary"
    type="radio"
    appearance="button"
    size="sm"
  />
</Choice>

Sizes

size (sm / md / lg) scales the items — and the container’s padding and the gap between items (which are always equal) shrink with it too. The container radius scales in step, so the items’ corners stay concentric inside it at every size. In Svelte the size set on <Choice> cascades to every <ChoiceItem>; in Astro (no runtime context) set size on each <ChoiceItem> as well.

---
// GENERATED from packages/demos/choice/sizes.demo.tsx — edit the source, not this file.
import Choice from "@pindoba/astro-choice";
import { ChoiceItem } from "@pindoba/astro-choice";
import { stack } from "@pindoba/styled-system/patterns";

const sizes = ["sm", "md", "lg"] as const;
---

<div class={stack({ gap: "lg", direction: "column", align: "start" })}>
  {sizes.map((size) => (
    <Choice type="radio" appearance="button" tone="primary" size={size}>
      <ChoiceItem
        value="s"
        label="Small"
        type="radio"
        appearance="button"
        tone="primary"
        size={size}
      />
      <ChoiceItem
        value="m"
        label="Medium"
        type="radio"
        appearance="button"
        tone="primary"
        size={size}
        checked
      />
      <ChoiceItem
        value="l"
        label="Large"
        type="radio"
        appearance="button"
        tone="primary"
        size={size}
      />
    </Choice>
  ))}
</div>

Affixes

Button-appearance items take leading / trailing affix content, forwarded to the wrapped Radio/Checkbox — an icon <Stamp emphasis="ghost">, a <Badge> count. Each segment rounds like a standalone control of the same size, so its affixes nest concentrically inside it. Pass them as leading/trailing on an items entry, as snippets (Svelte) or as slot="leading" / slot="trailing" children (Astro).

---
// GENERATED from packages/demos/choice/affixes.demo.tsx — edit the source, not this file.
import Choice from "@pindoba/astro-choice";
import { ChoiceItem } from "@pindoba/astro-choice";
import Stamp from "@pindoba/astro-stamp";
import Badge from "@pindoba/astro-badge";
import { List, LayoutGrid, Calendar } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";

const sizes = ["sm", "md", "lg"] as const;
---

<div class={stack({ gap: "lg", direction: "column", align: "start" })}>
  {sizes.map((size) => (
    <Choice type="radio" appearance="button" tone="primary" size={size}>
      <ChoiceItem
        value="list"
        label="List"
        type="radio"
        appearance="button"
        tone="primary"
        size={size}
      >
        <Stamp slot="leading" emphasis="ghost">
          <List />
        </Stamp>
      </ChoiceItem>
      <ChoiceItem
        value="board"
        label="Board"
        type="radio"
        appearance="button"
        tone="primary"
        size={size}
        checked
      >
        <Stamp slot="leading" emphasis="ghost">
          <LayoutGrid />
        </Stamp>
        <Badge slot="trailing">3</Badge>
      </ChoiceItem>
      <ChoiceItem
        value="calendar"
        label="Calendar"
        type="radio"
        appearance="button"
        tone="primary"
        size={size}
      >
        <Stamp slot="leading" emphasis="ghost">
          <Calendar />
        </Stamp>
      </ChoiceItem>
    </Choice>
  ))}
</div>

Emphasis

emphasis (primary / secondary / tertiary) tunes the control treatment. Set on <Choice> it cascades to every item (Svelte); in Astro set it per <ChoiceItem>.

---
// GENERATED from packages/demos/choice/emphasis.demo.tsx — edit the source, not this file.
import Choice from "@pindoba/astro-choice";
import { ChoiceItem } from "@pindoba/astro-choice";
import { stack } from "@pindoba/styled-system/patterns";

const emphases = ["primary", "secondary", "tertiary"] as const;
---

<div class={stack({ gap: "lg", direction: "column", align: "start" })}>
  {emphases.map((emphasis) => (
    <Choice type="radio" appearance="button" tone="primary" emphasis={emphasis}>
      <ChoiceItem
        value="day"
        label="Day"
        type="radio"
        appearance="button"
        tone="primary"
        emphasis={emphasis}
      />
      <ChoiceItem
        value="week"
        label="Week"
        type="radio"
        appearance="button"
        tone="primary"
        emphasis={emphasis}
        checked
      />
      <ChoiceItem
        value="month"
        label="Month"
        type="radio"
        appearance="button"
        tone="primary"
        emphasis={emphasis}
      />
    </Choice>
  ))}
</div>

Tone

tone (neutral / primary / success / warning / danger) colors the selected state. Like emphasis, it cascades from <Choice> in Svelte and is set per item in Astro.

---
// GENERATED from packages/demos/choice/tone.demo.tsx — edit the source, not this file.
import Choice from "@pindoba/astro-choice";
import { ChoiceItem } from "@pindoba/astro-choice";
import { stack } from "@pindoba/styled-system/patterns";

const feedbacks = [
  "neutral",
  "primary",
  "success",
  "warning",
  "danger",
] as const;
---

<div class={stack({ gap: "lg", direction: "column", align: "start" })}>
  {feedbacks.map((tone) => (
    <Choice type="radio" appearance="button" tone={tone}>
      <ChoiceItem
        value="a"
        label="Low"
        type="radio"
        appearance="button"
        tone={tone}
      />
      <ChoiceItem
        value="b"
        label="Medium"
        type="radio"
        appearance="button"
        tone={tone}
        checked
      />
      <ChoiceItem
        value="c"
        label="High"
        type="radio"
        appearance="button"
        tone={tone}
      />
    </Choice>
  ))}
</div>

Orientation & columns

orientation is horizontal (default, wraps) or vertical. For a responsive multi-column layout — especially with cards — set columns: a number lays the items out in that many equal columns, and "auto" makes a responsive auto-fit grid that reflows to fill the available width.

---
// GENERATED from packages/demos/choice/columns.demo.tsx — edit the source, not this file.
import Choice from "@pindoba/astro-choice";
import { ChoiceItem } from "@pindoba/astro-choice";
import { stack } from "@pindoba/styled-system/patterns";

const plans = [
  { value: "basic", heading: "Basic", subheading: "$0/mo" },
  { value: "pro", heading: "Pro", subheading: "$12/mo" },
  { value: "team", heading: "Team", subheading: "$29/mo" },
];
const features = [
  { value: "ssl", heading: "SSL", subheading: "Encrypted traffic" },
  { value: "cdn", heading: "CDN", subheading: "Global edge cache" },
  { value: "backups", heading: "Backups", subheading: "Daily snapshots" },
  { value: "analytics", heading: "Analytics", subheading: "Usage insights" },
];
---

<div class={stack({ gap: "xl", direction: "column" })}>
  <Choice type="radio" appearance="card" size="sm" columns={3} tone="primary">
    {plans.map((p) => (
      <ChoiceItem
        value={p.value}
        header={{ heading: p.heading, subheading: p.subheading }}
        type="radio"
        appearance="card"
        size="sm"
        tone="primary"
        checked={(["pro"] as string[]).includes(p.value)}
      />
    ))}
  </Choice>

  <Choice
    type="checkbox"
    appearance="card"
    size="sm"
    columns="auto"
    tone="success"
  >
    {features.map((f) => (
      <ChoiceItem
        value={f.value}
        header={{ heading: f.heading, subheading: f.subheading }}
        type="checkbox"
        appearance="card"
        size="sm"
        tone="success"
        checked={(["ssl"] as string[]).includes(f.value)}
      />
    ))}
  </Choice>
</div>

Grouping & background

The group container is a Panel. By default it paints a grouped surface — a deep background with a muted outline — so the options read as one group out of the box. Tune it with background, border, radius, padding, shadow, plus the tone/emphasis colorway (set background="transparent" / border="none" to drop the framing entirely). Set group to merge the items into a single segmented control (shared dividers and a flush outline) via the Group component.

---
// GENERATED from packages/demos/choice/grouped.demo.tsx — edit the source, not this file.
import Choice from "@pindoba/astro-choice";
import { ChoiceItem } from "@pindoba/astro-choice";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "xl", direction: "column", align: "start" })}>
  <Choice type="radio" appearance="button" tone="primary" group>
    <ChoiceItem
      value="list"
      label="List"
      type="radio"
      appearance="button"
      tone="primary"
      group
      checked
    />
    <ChoiceItem
      value="board"
      label="Board"
      type="radio"
      appearance="button"
      tone="primary"
      group
    />
    <ChoiceItem
      value="calendar"
      label="Calendar"
      type="radio"
      appearance="button"
      tone="primary"
      group
    />
  </Choice>

  <Choice
    type="radio"
    appearance="card"
    size="sm"
    orientation="vertical"
    background="surface.base"
  >
    <ChoiceItem
      value="basic"
      header={{ heading: "Basic", subheading: "$0/mo" }}
      type="radio"
      appearance="card"
      size="sm"
    />
    <ChoiceItem
      value="pro"
      header={{ heading: "Pro", subheading: "$12/mo" }}
      type="radio"
      appearance="card"
      size="sm"
      checked
    />
    <ChoiceItem
      value="team"
      header={{ heading: "Team", subheading: "$29/mo" }}
      type="radio"
      appearance="card"
      size="sm"
    />
  </Choice>
</div>

Selectable cards

appearance="card" renders each option as a real Card (rendered as="label", so the whole card is the control), with the indicator in the card’s Banner leading slot. Pass header (and optionally footer/children) to lay out structured content. Cards are interactive (hover/active surface) and their corners nest concentrically inside the group container’s radius. The indicator sits centered on the heading’s first line. The selected card’s border takes the accent color, with a faint tint. Both are driven purely by CSS, follow the tone colorway (tone="success" reads green, etc.), and update at runtime in every framework, Astro included. Keyboard focus rings the whole card.

Selected plan: pro

---
// GENERATED from packages/demos/choice/cards.demo.tsx — edit the source, not this file.
import Choice from "@pindoba/astro-choice";
import { ChoiceItem } from "@pindoba/astro-choice";

const plan = ["pro"] as string[];
---

<Choice
  type="radio"
  appearance="card"
  size="sm"
  orientation="vertical"
  data-ir-root="choice/cards"
  data-ir="1"
>
  <ChoiceItem
    value="basic"
    header={{ heading: "Basic Plan", subheading: "$10/month" }}
    type="radio"
    appearance="card"
    size="sm"
  />
  <ChoiceItem
    value="pro"
    header={{ heading: "Pro Plan", subheading: "$20/month" }}
    type="radio"
    appearance="card"
    size="sm"
    checked
  />
  <ChoiceItem
    value="team"
    header={{ heading: "Team Plan", subheading: "$40/month" }}
    type="radio"
    appearance="card"
    size="sm"
  />
</Choice>

<p data-ir-root="choice/cards">
  Selected plan: <!--ir:2-->
  {plan.join(", ")}
  <!--/ir-->
</p>

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

  const roots = [...document.querySelectorAll('[data-ir-root="choice/cards"]')];
  for (let i = 0; i < roots.length; i += 2) hydrate(roots.slice(i, i + 2));
</script>

Multi-select cards

type="checkbox" turns the cards into independent toggles — the indicator becomes a checkbox and value is an array.

Enabled: analytics

---
// GENERATED from packages/demos/choice/cards-checkbox.demo.tsx — edit the source, not this file.
import Choice from "@pindoba/astro-choice";
import { ChoiceItem } from "@pindoba/astro-choice";

const features = ["analytics"] as string[];
---

<Choice
  type="checkbox"
  appearance="card"
  size="sm"
  orientation="vertical"
  data-ir-root="choice/cards-checkbox"
  data-ir="1"
>
  <ChoiceItem
    value="analytics"
    header={{
      heading: "Analytics",
      subheading: "Usage dashboards & reports",
    }}
    type="checkbox"
    appearance="card"
    size="sm"
    checked
  />
  <ChoiceItem
    value="alerts"
    header={{
      heading: "Alerts",
      subheading: "Email & Slack notifications",
    }}
    type="checkbox"
    appearance="card"
    size="sm"
  />
  <ChoiceItem
    value="audit"
    header={{ heading: "Audit log", subheading: "Track every change" }}
    type="checkbox"
    appearance="card"
    size="sm"
  />
</Choice>

<p data-ir-root="choice/cards-checkbox">
  Enabled: <!--ir:2-->
  {features.join(", ") || "none"}
  <!--/ir-->
</p>

<script>
  import { hydrate } from "./_client/cards-checkbox.ts";

  const roots = [
    ...document.querySelectorAll('[data-ir-root="choice/cards-checkbox"]'),
  ];
  for (let i = 0; i < roots.length; i += 2) hydrate(roots.slice(i, i + 2));
</script>

Stat cards

Selectable cards aren’t limited to plain text — compose Stamp, Badge, and any layout inside. indicator picks how a card shows its selection:

  • inline (default): the radio/checkbox in the card header.
  • float: a check badge pinned on the card’s top-end corner, straddling the border so it never sits on the card’s content. It’s an empty ring at rest and fills with a check when selected, so selection doesn’t rely on color alone. Handy for metric tiles like these.
  • none: no indicator; the accent border alone shows selection.

The real input is kept for keyboard and a11y in every mode. (hideIndicator is deprecated; it maps to indicator="none".)

---
// GENERATED from packages/demos/choice/stat.demo.tsx — edit the source, not this file.
import Choice from "@pindoba/astro-choice";
import { ChoiceItem } from "@pindoba/astro-choice";
import Stamp from "@pindoba/astro-stamp";
import Badge from "@pindoba/astro-badge";
import { Zap, TriangleAlert, TrendingUp, TrendingDown } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { flex, grid } from "@pindoba/styled-system/patterns";

const metrics = [
  {
    value: "errors",
    n: "7",
    label: "Errors",
    delta: "-2",
    tone: "danger" as const,
  },
  {
    value: "warnings",
    n: "12",
    label: "Warnings",
    delta: "+3",
    tone: "warning" as const,
  },
];
---

<Choice
  type="radio"
  appearance="card"
  size="sm"
  indicator="float"
  passThrough={{ root: { style: grid.raw({ columns: 2, gap: "md" }) } }}
>
  {metrics.map((m) => {
    const rising = m.delta.startsWith("+");
    return (
      <ChoiceItem
        value={m.value}
        header={{}}
        type="radio"
        appearance="card"
        size="sm"
        indicator="float"
        checked={(["errors"] as string[]).includes(m.value)}
      >
        <Stamp
          slot="header-leading"
          shape="square"
          emphasis="muted"
          tone={m.tone}
        >
          {m.value === "errors" ? (
            <>
              <Zap size={18} />
            </>
          ) : (
            <>
              <TriangleAlert size={18} />
            </>
          )}
        </Stamp>
        <Badge slot="header-trailing" tone={m.tone} emphasis="secondary">
          <span class={flex({ gap: "3xs", align: "center" })}>
            {rising ? (
              <>
                <TrendingUp size={12} />
              </>
            ) : (
              <>
                <TrendingDown size={12} />
              </>
            )}
            {m.delta}
          </span>
        </Badge>
        <div
          class={css({
            fontSize: "5xl",
            fontWeight: "bold",
            lineHeight: "1",
            color: "neutral.text.bold",
          })}
        >
          {m.n}
        </div>
        <div class={css({ color: "neutral.text", fontSize: "sm" })}>
          {m.label}
        </div>
      </ChoiceItem>
    );
  })}
</Choice>

Checkboxes

type="checkbox" allows independent multi-selection. value is always an array.

Accepted: terms

---
// GENERATED from packages/demos/choice/checkboxes.demo.tsx — edit the source, not this file.
import Choice from "@pindoba/astro-choice";
import { ChoiceItem } from "@pindoba/astro-choice";

const agreements = ["terms"] as string[];
---

<Choice
  type="checkbox"
  appearance="checkbox"
  orientation="vertical"
  data-ir-root="choice/checkboxes"
  data-ir="1"
>
  <ChoiceItem
    value="terms"
    label="I accept the terms"
    type="checkbox"
    appearance="checkbox"
    checked
  />
  <ChoiceItem
    value="newsletter"
    label="Subscribe to newsletter"
    type="checkbox"
    appearance="checkbox"
  />
  <ChoiceItem
    value="offers"
    label="Send me product offers"
    type="checkbox"
    appearance="checkbox"
  />
</Choice>

<p data-ir-root="choice/checkboxes">
  Accepted: <!--ir:2-->
  {agreements.join(", ") || "none"}
  <!--/ir-->
</p>

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

  const roots = [
    ...document.querySelectorAll('[data-ir-root="choice/checkboxes"]'),
  ];
  for (let i = 0; i < roots.length; i += 2) hydrate(roots.slice(i, i + 2));
</script>

Items array

Instead of slotting children, pass an items array — the data-driven mode used by TabNav/Accordion. Each entry becomes a fully-wired item.

Selected: weekly

---
// GENERATED from packages/demos/choice/items.demo.tsx — edit the source, not this file.
import Choice from "@pindoba/astro-choice";

const items = [
  { value: "daily", label: "Daily" },
  { value: "weekly", label: "Weekly" },
  { value: "monthly", label: "Monthly", disabled: true },
];

const value = ["weekly"] as string[];
---

<Choice
  type="radio"
  appearance="button"
  items={items}
  value={["weekly"] as string[]}
  data-ir-root="choice/items"
  data-ir="1"
/>

<p data-ir-root="choice/items">
  Selected: <!--ir:2-->
  {value.join(", ")}
  <!--/ir-->
</p>

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

  const roots = [...document.querySelectorAll('[data-ir-root="choice/items"]')];
  for (let i = 0; i < roots.length; i += 2) hydrate(roots.slice(i, i + 2));
</script>
  • Components
  • Blocks