component — selection
Choice Box
Selectable cards built on react-aria-components, styled with the quebi design system. Supports single or multiple selection, 1–6 column layouts, label/description slots, and readonly, invalid, and disabled states.
- form
- input
- select
- card
- radio
- interactive
06 examples
Single selection
A stacked group where one card can be selected.
StarterFor solo projects and prototypes.
ProFor growing teams that ship often.
EnterpriseAdvanced controls and support.
Multiple selection
Selection mode multiple renders a checkbox per card.
AnalyticsUsage dashboards and reports.
BackupsDaily off-site snapshots.
SSOSAML and SCIM provisioning.
Columns
Lay cards out in a responsive grid.
Light
Dark
System
Custom content
Compose labels and descriptions with JSX children.
Monthly$12 / month, billed monthly.
Yearly$120 / year, save 17%.
Disabled & readonly
Disable individual cards, or make the whole group read-only.
EuropeFrankfurt (eu-central-1).
United StatesCurrently unavailable.
Controlled
EcoLower cost, slower.
BalancedA sensible default.
TurboFastest, higher cost.
source
choice-box.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import { Children, createContext, isValidElement, type ReactNode, use } from "react"
import type { GridListItemProps, GridListProps, TextProps } from "react-aria-components"
import { composeRenderProps, GridList, GridListItem, Text } from "react-aria-components"
import type { VariantProps } from "tailwind-variants"
import { Checkbox } from "@/components/checkbox"
import { cn, tv } from "@/lib/utils"
/**
* ChoiceBox — quebi design system
*
* Selectable cards built on react-aria-components' GridList. Single or multiple
* selection, 1–6 column layouts, optional label/description/icon/avatar slots.
* Hairline-edged tiles at the control radius (a collapsed stack rounds only
* its outer corners). Hover lifts a tile onto `raised`; a selected tile is
* state, so it is edged in `signal` and sits on `selected`, the library's
* selected-row ground (and in multiple mode its checkbox fills with `signal`). Focus is the
* outward ring; invalid edges the tile in `danger`.
*/
/**
* Walks a React children tree for the first `ChoiceBoxLabel` and returns its
* textual content. Lets callers pass label/description as JSX without having
* to also pass `textValue` for accessibility.
*/
function deriveTextValueFromChildren(children: ReactNode): string | undefined {
let found: string | undefined
const visit = (nodes: ReactNode): void => {
if (found !== undefined) return
Children.forEach(nodes, (child) => {
if (found !== undefined) return
if (typeof child === "string") return
if (!isValidElement(child)) return
const elementType = child.type as unknown
if (elementType === ChoiceBoxLabel) {
const labelChildren = (child.props as { children?: ReactNode }).children
if (typeof labelChildren === "string") found = labelChildren
else if (typeof labelChildren === "number") found = String(labelChildren)
return
}
const innerChildren = (child.props as { children?: ReactNode }).children
if (innerChildren) visit(innerChildren)
})
}
visit(children)
return found
}
const choiceBoxStyles = tv({
base: "grid [--gutter:--spacing(4)]",
variants: {
columns: {
1: "col-span-full grid-cols-[auto_1fr]",
2: "grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(min(20rem,100%),26rem))] sm:justify-center",
3: "grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(min(16rem,100%),22rem))] sm:justify-center",
4: "grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(min(14rem,100%),18rem))] sm:justify-center",
5: "grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(min(12rem,100%),16rem))] sm:justify-center",
6: "grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(min(11rem,100%),14rem))] sm:justify-center",
},
gap: {
0: "gap-0",
1: "gap-1",
2: "gap-2",
3: "gap-3",
4: "gap-4",
5: "gap-5",
6: "gap-6",
8: "gap-8",
10: "gap-10",
12: "gap-12",
},
},
defaultVariants: {
columns: 1,
gap: 0,
},
compoundVariants: [
{
gap: 0,
columns: 1,
className:
"*:data-[slot=choice-box-item]:-mt-px *:data-[slot=choice-box-item]:not-first:rounded-t-none *:data-[slot=choice-box-item]:not-last:rounded-b-none",
},
],
})
const ChoiceBoxContext = createContext<{ columns?: number; gap?: number; isReadOnly?: boolean }>({})
const useChoiceBoxContext = () => use(ChoiceBoxContext)
interface ChoiceBoxProps<T extends object>
extends GridListProps<T>,
VariantProps<typeof choiceBoxStyles> {
isReadOnly?: boolean
}
const ChoiceBox = <T extends object>({
columns = 1,
gap = 0,
className,
selectionMode = "single",
isReadOnly,
...props
}: ChoiceBoxProps<T>) => {
return (
<ChoiceBoxContext value={{ columns, gap, isReadOnly }}>
<GridList
data-slot="choice-box"
layout={columns === 1 ? "stack" : "grid"}
selectionMode={selectionMode}
className={composeRenderProps(className, (resolved) =>
cn(
choiceBoxStyles({
columns,
gap,
}),
resolved,
),
)}
{...props}
/>
</ChoiceBoxContext>
)
}
const choiceBoxItemStyles = tv({
base: [
"group outline-hidden",
"rounded-(--q-radius-control) border border-quebi-hairline bg-quebi-bg p-(--gutter) **:data-[slot=label]:font-medium",
"transition-colors duration-150",
"**:data-[slot=avatar]:row-span-2 **:data-[slot=avatar]:mt-0.5 **:data-[slot=avatar]:shrink-0",
"**:data-[slot=icon]:row-span-2 **:data-[slot=icon]:mt-0.5 **:data-[slot=icon]:shrink-0",
"has-data-[slot=avatar]:grid-cols-[auto_1fr_auto] has-data-[slot=icon]:grid-cols-[auto_1fr_auto]",
"grid grid-cols-[1fr_auto] content-start items-start gap-x-[calc(var(--gutter)-(--spacing(1)))] gap-y-1",
"has-[[slot=description]]:**:data-[slot=label]:font-medium",
],
variants: {
isLink: {
true: "cursor-pointer",
false: "cursor-default",
},
isHovered: {
true: "not-data-readonly:not-selected:bg-quebi-raised",
},
isFocused: {
true: "ring-2 ring-quebi-focus ring-offset-2 ring-offset-quebi-bg invalid:ring-quebi-danger/50",
},
isInvalid: { true: "border-quebi-danger" },
isOneColumn: {
true: "col-span-full",
},
isActive: {
true: ["border-quebi-signal bg-quebi-selected"],
},
isDisabled: {
true: "opacity-50 **:data-[slot=label]:text-quebi-fg-muted forced-colors:text-[GrayText] **:[[slot=description]]:text-quebi-fg-subtle",
},
/**
* Which layer of the stack the card is painted on.
*
* In the collapsed one-column layout the cards overlap by a pixel
* (`*:data-[slot=choice-box-item]:-mt-px` above) so they share a hairline,
* and each card is opaque — so a card paints over the bottom edge of the
* one before it, and over anything that card draws outside its border box,
* unless that card is lifted. Every state that draws outward needs a layer.
*
* They live in one variant rather than as a `z-*` on each state because
* `tv` emits variants in the order of the keys above and tailwind-merge
* keeps the last `z-*` it sees: a card that is both focused and selected
* would otherwise take whichever of the two happened to be declared lower
* down, which is how the focus ring of a card that had just been deselected
* ended up under its neighbour (task #113).
*/
elevation: {
/** A ring, drawn outside the border: has to clear a *selected* neighbour. */
ring: "z-30",
/** A `signal` border on all four edges: has to clear a plain neighbour. */
selected: "z-20",
/** Faded, but still its own edges rather than the next card's. */
disabled: "z-10",
},
},
})
interface ChoiceBoxItemProps
extends GridListItemProps,
// `elevation` is derived from the item's own state below rather than chosen
// by the caller, so it is not part of the prop surface.
Omit<VariantProps<typeof choiceBoxItemStyles>, "elevation"> {
label?: string
description?: string
}
const ChoiceBoxItem = ({
className,
label,
description,
children,
isInvalid,
textValue: textValueProp,
...props
}: ChoiceBoxItemProps) => {
// Prefer an explicit textValue prop; otherwise extract it from string children,
// a nested ChoiceBoxLabel, or the label prop — in that priority order.
const textValue =
textValueProp ??
(typeof children === "string"
? children
: typeof children === "function"
? label
: (deriveTextValueFromChildren(children as ReactNode) ?? label))
const { columns, isReadOnly } = useChoiceBoxContext()
return (
<GridListItem
textValue={textValue}
data-readonly={isReadOnly}
data-slot="choice-box-item"
{...props}
className={composeRenderProps(
className,
(className, { isFocusVisible, isSelected, ...renderProps }) => {
const isFocused = !isReadOnly && renderProps.isFocused
const isActive = (!isReadOnly && isSelected) || isFocusVisible
const drawsRing = isFocused || isInvalid
return choiceBoxItemStyles({
...renderProps,
isOneColumn: columns === 1,
isLink: "href" in props,
isFocused,
isInvalid,
isActive,
elevation: drawsRing
? "ring"
: isActive
? "selected"
: renderProps.isDisabled
? "disabled"
: undefined,
className,
})
},
)}
>
{composeRenderProps(children, (children, { selectionMode }) => {
const isStringChild = typeof children === "string"
const hasCustomChildren = typeof children !== "undefined"
const content = hasCustomChildren ? (
isStringChild ? (
<ChoiceBoxLabel>{children}</ChoiceBoxLabel>
) : (
children
)
) : (
<>
{label && <ChoiceBoxLabel>{label}</ChoiceBoxLabel>}
{description && <ChoiceBoxDescription>{description}</ChoiceBoxDescription>}
</>
)
return (
<>
{content}
{selectionMode === "multiple" && (
<Checkbox
className="col-start-2 self-start group-has-data-[slot=avatar]:col-start-3 group-has-data-[slot=icon]:col-start-3"
slot="selection"
/>
)}
</>
)
})}
</GridListItem>
)
}
interface ChoiceBoxLabelProps extends TextProps {
ref?: React.Ref<HTMLDivElement>
}
const ChoiceBoxLabel = ({ className, ref, ...props }: ChoiceBoxLabelProps) => {
return (
<Text
data-slot="label"
ref={ref}
className={cn(
"select-none text-base/6 text-quebi-fg group-disabled:opacity-50 sm:text-sm/6",
"col-start-1 row-start-1",
"group-has-data-[slot=icon]:col-start-2",
"group-has-data-[slot=avatar]:col-start-2",
className,
)}
{...props}
/>
)
}
type ChoiceBoxDescriptionProps = ChoiceBoxLabelProps
const ChoiceBoxDescription = ({ className, ref, ...props }: ChoiceBoxDescriptionProps) => {
return (
<Text
slot="description"
ref={ref}
className={cn(
"col-start-1 row-start-2",
"group-has-data-[slot=icon]:col-start-2",
"group-has-data-[slot=avatar]:col-start-2",
"text-base/6 text-quebi-fg-muted sm:text-sm/6",
"group-disabled:opacity-50",
className,
)}
{...props}
/>
)
}
export type { ChoiceBoxItemProps, ChoiceBoxProps }
export { ChoiceBox, ChoiceBoxDescription, ChoiceBoxItem, ChoiceBoxLabel }