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.
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>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>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>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>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 (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 (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 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>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>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>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>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>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>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>