Forms
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.
forminputselectcardradiointeractive
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
Copy 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 { tv } from "tailwind-variants"
import { Checkbox } from "@/components/checkbox"
import { cn } 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.
* Selected cards take the brand teal border with a faint teal wash; focus uses
* the quebi teal ring; invalid uses red.
*/
/**
* 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:
"rounded-quebi-md *:data-[slot=choice-box-item]:-mt-px *:data-[slot=choice-box-item]:rounded-none *:data-[slot=choice-box-item]:last:rounded-b-quebi-md *:data-[slot=choice-box-item]:first:rounded-t-quebi-md",
},
],
})
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={cn(
choiceBoxStyles({
columns,
gap,
}),
className,
)}
{...props}
/>
</ChoiceBoxContext>
)
}
const choiceBoxItemStyles = tv({
base: [
"group outline-hidden",
"rounded-quebi-md border border-cyan-500/20 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-data-focus-visible:not-selected:border-cyan-500/40",
},
isFocused: {
true: "ring-2 ring-quebi-brand/50 ring-offset-2 ring-offset-quebi-bg invalid:ring-red-500/50",
},
isInvalid: { true: "border-red-500 ring-2 ring-red-500/40" },
isOneColumn: {
true: "col-span-full",
},
isActive: {
true: ["z-20 border-quebi-brand bg-quebi-brand/5"],
},
isDisabled: {
true: "z-10 opacity-50 **:data-[slot=label]:text-quebi-fg-muted forced-colors:text-[GrayText] **:[[slot=description]]:text-quebi-fg-muted/70",
},
},
})
interface ChoiceBoxItemProps extends GridListItemProps, VariantProps<typeof choiceBoxItemStyles> {
label?: string
description?: string
}
const ChoiceBoxItem = ({
className,
label,
description,
children,
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 }) =>
choiceBoxItemStyles({
...renderProps,
isOneColumn: columns === 1,
isLink: "href" in props,
isFocused: !isReadOnly && renderProps.isFocused,
isActive: (!isReadOnly && isSelected) || isFocusVisible,
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-white 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 }