component
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.
---
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>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>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 (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 (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 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>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>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>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>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>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>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"]} />buttonVisual appearance variant of the control.
Root container background. Defaults to `transparent`.
Root container border. Defaults to `none`.
No description yet.
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.
Uncontrolled initial selection.
No description yet.
No description yet.
"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.
Semantic color tone: `neutral`, `primary`, `success`, `warning`, `danger`, or `inherit`.
Stretch the component to fill the available width.
Wrap the items in a `Group` so they read as a single, merged control (shared dividers + flush outline). Best with `appearance="button"`.
Card appearance only: visually hide the radio/checkbox indicator (the real input is kept for selection + a11y; selection reads from the accent ring).
Items-array mode input. Mutually exclusive with slotted children.
Shared radio group name. Auto-generated when omitted.
No description yet.
horizontalNo description yet.
Root container padding. Defaults to `none`.
No description yet.
Root container corner radius. Defaults to `none`.
Root container shadow. Defaults to `none`.
mdSize variant.
No description yet.
Bindable selected value(s). Always an array, even for radio (length ≤ 1).
Plus all standard <div> HTML
attributes.