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.

---
import Choice from "../Choice.astro";
import ChoiceItem from "../ChoiceItem.astro";
---

<Choice type="radio" defaultValue={["daily"]}>
  <ChoiceItem value="daily" label="Daily" checked />
  <ChoiceItem value="weekly" label="Weekly" />
  <ChoiceItem value="monthly" label="Monthly" />
</Choice>

Segmented control

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

---
import Choice from "../Choice.astro";
import type { ChoiceItemInput } from "@pindoba/core-choice";
import { stack } from "@pindoba/styled-system/patterns";

// Items-array mode: the parent <Choice> resolves every item's props
// server-side (feedback, emphasis, selection), so the group styling cascades
// without per-item repetition — the Astro-native alternative to Svelte's
// context cascade.
const viewItems: ChoiceItemInput[] = [
  { value: "list", label: "List" },
  { value: "board", label: "Board" },
  { value: "calendar", label: "Calendar" },
];

const sizeItems: ChoiceItemInput[] = [
  { value: "s", label: "Small" },
  { value: "m", label: "Medium" },
  { value: "l", label: "Large" },
];
---

<div class={stack({ gap: "lg", direction: "column" })}>
  <Choice
    type="radio"
    appearance="button"
    feedback="primary"
    items={viewItems}
    defaultValue={["list"]}
  />

  <Choice
    type="radio"
    appearance="button"
    feedback="success"
    items={sizeItems}
    defaultValue={["m"]}
  />
</div>

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.

---
import Choice from "../Choice.astro";
import ChoiceItem from "../ChoiceItem.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" feedback="primary" size={size}>
        <ChoiceItem value="s" label="Small" size={size} />
        <ChoiceItem value="m" label="Medium" size={size} checked />
        <ChoiceItem value="l" label="Large" size={size} />
      </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>.

---
import Choice from "../Choice.astro";
import ChoiceItem from "../ChoiceItem.astro";
import { stack } from "@pindoba/styled-system/patterns";

const emphases = ["primary", "secondary", "tertiary"] as const;
const options = ["Day", "Week", "Month"];
---

<div class={stack({ gap: "lg", direction: "column", align: "start" })}>
  {
    emphases.map((emphasis) => (
      <Choice type="radio" appearance="button" feedback="primary">
        {options.map((label, i) => (
          <ChoiceItem
            value={label.toLowerCase()}
            label={label}
            emphasis={emphasis}
            feedback="primary"
            checked={i === 1}
          />
        ))}
      </Choice>
    ))
  }
</div>

Feedback

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

---
import Choice from "../Choice.astro";
import ChoiceItem from "../ChoiceItem.astro";
import { stack } from "@pindoba/styled-system/patterns";

const feedbacks = [
  "neutral",
  "primary",
  "success",
  "warning",
  "danger",
] as const;
const options = [
  { value: "a", label: "Low" },
  { value: "b", label: "Medium" },
  { value: "c", label: "High" },
];
---

<div class={stack({ gap: "lg", direction: "column", align: "start" })}>
  {
    feedbacks.map((feedback) => (
      <Choice type="radio" appearance="button">
        {options.map((o) => (
          <ChoiceItem
            value={o.value}
            label={o.label}
            feedback={feedback}
            checked={o.value === "b"}
          />
        ))}
      </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.

---
import Choice from "../Choice.astro";
import ChoiceItem from "../ChoiceItem.astro";
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" })}>
  {/* Fixed three-column grid. */}
  <Choice type="radio" appearance="card" columns={3} feedback="primary">
    {
      plans.map((p) => (
        <ChoiceItem
          value={p.value}
          appearance="card"
          size="sm"
          feedback="primary"
          checked={p.value === "pro"}
          header={{ heading: p.heading, subheading: p.subheading }}
        />
      ))
    }
  </Choice>

  {/* Responsive auto-fit grid: cards reflow to fill the available width. */}
  <Choice type="checkbox" appearance="card" columns="auto" feedback="success">
    {
      features.map((f) => (
        <ChoiceItem
          value={f.value}
          appearance="card"
          size="sm"
          feedback="success"
          checked={f.value === "ssl"}
          header={{ heading: f.heading, subheading: f.subheading }}
        />
      ))
    }
  </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 feedback/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.

---
import Choice from "../Choice.astro";
import type { ChoiceItemInput } from "@pindoba/core-choice";
import { stack } from "@pindoba/styled-system/patterns";

// Items-array mode: the parent <Choice> resolves every item's props
// server-side. This is how group-level config (feedback, the group→secondary
// emphasis, the merged <Group> wrapper) cascades in Astro — no parent context
// exists, so slotted items would otherwise need the props repeated per item.
const viewItems: ChoiceItemInput[] = [
  { value: "list", label: "List" },
  { value: "board", label: "Board" },
  { value: "calendar", label: "Calendar" },
];

const planItems: ChoiceItemInput[] = [
  { value: "basic", header: { heading: "Basic", subheading: "$0/mo" } },
  { value: "pro", header: { heading: "Pro", subheading: "$12/mo" } },
  { value: "team", header: { heading: "Team", subheading: "$29/mo" } },
];
---

<div class={stack({ gap: "xl", direction: "column", align: "start" })}>
  {
    /* `group` merges the buttons into one segmented control (shared dividers,
       flush outline) by wrapping them in a <Group>. */
  }
  <Choice
    type="radio"
    appearance="button"
    feedback="primary"
    group
    items={viewItems}
    defaultValue={["list"]}
  />

  {
    /* A background + padding on the root turns the set of cards into a single
       perceived group. */
  }
  <Choice
    type="radio"
    appearance="card"
    size="sm"
    orientation="vertical"
    background="surface.base"
    padding="md"
    radius="xl"
    items={planItems}
    defaultValue={["pro"]}
  />
</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 selected card gets a ring driven purely by CSS — it follows the feedback colorway (feedback="success" rings green, etc.) and updates at runtime in both Svelte and Astro.

---
import Choice from "../Choice.astro";
import ChoiceItem from "../ChoiceItem.astro";
---

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

Multi-select cards

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

---
import Choice from "../Choice.astro";
import ChoiceItem from "../ChoiceItem.astro";
---

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

Stat cards

Selectable cards aren’t limited to plain text — compose Stamp, Badge, and any layout inside. Set hideIndicator to drop the radio/checkbox entirely and let the accent ring convey selection — handy for metric tiles. (The real input is kept for keyboard + a11y; only its visual is hidden.)

---
import Choice from "../Choice.astro";
import ChoiceItem from "../ChoiceItem.astro";
import Stamp from "@pindoba/astro-stamp";
import Badge from "@pindoba/astro-badge";
import { TrendingDown, TrendingUp, TriangleAlert, Zap } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { flex, grid } from "@pindoba/styled-system/patterns";

const metrics = [
  {
    value: "errors",
    icon: Zap,
    n: "7",
    label: "Errors",
    delta: "-2",
    trend: TrendingDown,
    feedback: "danger" as const,
  },
  {
    value: "warnings",
    icon: TriangleAlert,
    n: "12",
    label: "Warnings",
    delta: "+3",
    trend: TrendingUp,
    feedback: "warning" as const,
  },
];
---

<Choice
  type="radio"
  appearance="card"
  defaultValue={["errors"]}
  passThrough={{ root: { style: grid.raw({ columns: 2, gap: "md" }) } }}
>
  {
    metrics.map((m) => {
      const StatIcon = m.icon;
      const TrendIcon = m.trend;
      return (
        <ChoiceItem
          appearance="card"
          size="sm"
          hideIndicator
          value={m.value}
          checked={m.value === "errors"}
        >
          <Stamp
            slot="header-leading"
            shape="square"
            emphasis="muted"
            feedback={m.feedback}
          >
            <StatIcon />
          </Stamp>
          <Badge
            slot="header-trailing"
            feedback={m.feedback}
            emphasis="secondary"
          >
            <span class={flex({ gap: "3xs", align: "center" })}>
              <TrendIcon />
              {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.

---
import Choice from "../Choice.astro";
import ChoiceItem from "../ChoiceItem.astro";
---

<Choice type="checkbox" appearance="checkbox" orientation="vertical">
  <ChoiceItem
    type="checkbox"
    appearance="checkbox"
    value="terms"
    label="I accept the terms"
    checked
  />
  <ChoiceItem
    type="checkbox"
    appearance="checkbox"
    value="newsletter"
    label="Subscribe to newsletter"
  />
  <ChoiceItem
    type="checkbox"
    appearance="checkbox"
    value="offers"
    label="Send me product offers"
  />
</Choice>

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.

---
import Choice from "../Choice.astro";
import type { ChoiceItemInput } from "@pindoba/core-choice";

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

<Choice type="radio" appearance="button" {items} defaultValue={["weekly"]} />
props · 24 shown · 24 total
appearance
"radio""checkbox""button""card"
default button

Visual appearance variant of the control.

background
"surface.peak""surface.hill""surface.base""surface.valley""surface.ground""transparent"

Root container background. Defaults to `transparent`.

border
"none""bold""default""muted""accent"

Root container border. Defaults to `none`.

children slot svelte
Snippet

No description yet.

columns
number"auto"

Lay items out in a CSS grid instead of the orientation's flex flow: - a number → that many equal columns (`repeat(n, minmax(0, 1fr))`). - `"auto"` → a responsive auto-fit grid that wraps as space allows. Unset keeps the flex flow (horizontal wraps, vertical stacks). Great for laying out cards.

defaultValue svelte
ChoiceValue[]

Uncontrolled initial selection.

disabled
boolean

No description yet.

element binding svelte
HTMLDivElementnull

No description yet.

emphasis
"primary""secondary""tertiary"
default "tertiary" (or "secondary" when `group` is true)

Emphasis applied to every item in the group. The default depends on `group`: `"secondary"` (palette-tinted segments) when `group` is true so the merged control reads as one surface, otherwise `"tertiary"` — the quietest look, plain segments until selected. An explicit value always wins.

feedback
"primary""neutral""success""warning""danger""inherit"

Semantic color tone: `neutral`, `primary`, `success`, `warning`, `danger`, or `inherit`.

fullWidth
boolean

Stretch the component to fill the available width.

group
boolean

Wrap the items in a `Group` so they read as a single, merged control (shared dividers + flush outline). Best with `appearance="button"`.

hideIndicator
boolean

Card appearance only: visually hide the radio/checkbox indicator (the real input is kept for selection + a11y; selection reads from the accent ring).

items svelte
ChoiceItemInput[]

Items-array mode input. Mutually exclusive with slotted children.

name svelte
string

Shared radio group name. Auto-generated when omitted.

onValueChange svelte
(value: ChoiceValue[]) => void

No description yet.

orientation
"horizontal""vertical"
default horizontal

No description yet.

padding
"sm""md""lg""xl""2xl""none""xs""3xl""4xl""5xl""6xl""7xl""8xl""4xs""3xs""2xs""9xl""10xl""11xl"

Root container padding. Defaults to `none`.

passThrough
ChoicePassThrough<RootElementAttributes>

No description yet.

radius
"sm""md""lg""xl""2xl""none""xs""3xl""4xl""5xl""6xl""full""2xs""inner""inherit"

Root container corner radius. Defaults to `none`.

shadow
"sm""md""lg""xl""none""xs"

Root container shadow. Defaults to `none`.

size
"sm""md""lg"
default md

Size variant.

type
"radio""checkbox"

No description yet.

value svelte
ChoiceValue[]

Bindable selected value(s). Always an array, even for radio (length ≤ 1).

Plus all standard <div> HTML attributes.

Type

  • Components
  • Blocks