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

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 { 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 }