Skip to content

ChoiceCardGroup

Form

Image and icon choices using native single- or multiple-selection form controls.

Image cover choicesMultiple icon choicesOne to three columnsDisabled option
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

Usage

Use for plan, template, or channel choices that need more explanation than a select menu. Own an array of option values; single selection reads its first entry, while multiple enables independent checkboxes. Every option needs a unique value, a readable label, and optional description, decorative icon, or image. Images are presentation only and use the original file without optimization. Native inputs participate in form submission under name. Keep interactive children out of icon slots.

Import
import { ChoiceCardGroup } from "@/components/ui/choice-card-group";

Shipped as editable source in src/components/ui/choice-card-group.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.

Component source

The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.

src/components/ui/choice-card-group.tsx
"use client";
import { useId, type ReactNode } from "react";
import NextImage from "next/image";
import { Check } from "lucide-react";
import { cn } from "@dashboardpack/core/lib/utils";
export interface ChoiceCardOption {
  value: string;
  label: string;
  description?: string;
  icon?: ReactNode;
  image?: string;
  disabled?: boolean;
}
export interface ChoiceCardGroupProps {
  label: string;
  options: ChoiceCardOption[];
  value: string[];
  onValueChange: (values: string[]) => void;
  multiple?: boolean;
  disabled?: boolean;
  name?: string;
  columns?: 1 | 2 | 3;
  className?: string;
}
export function ChoiceCardGroup({
  label,
  options,
  value,
  onValueChange,
  multiple = false,
  disabled,
  name,
  columns = 2,
  className,
}: ChoiceCardGroupProps) {
  const id = useId();
  return (
    <fieldset
      disabled={disabled}
      className={cn("min-w-0 space-y-3", className)}
    >
      <legend className="mb-2 text-sm font-medium">{label}</legend>
      <div
        className={cn(
          "grid gap-3",
          columns === 3
            ? "sm:grid-cols-3"
            : columns === 2
              ? "sm:grid-cols-2"
              : "grid-cols-1",
        )}
      >
        {options.map((option) => {
          const checked = multiple
            ? value.includes(option.value)
            : value[0] === option.value;
          return (
            <label
              key={option.value}
              className={cn(
                "relative flex min-w-0 cursor-pointer flex-col overflow-hidden rounded-xl border bg-card transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
                checked ? "border-primary bg-primary/5" : "hover:bg-muted/30",
                (disabled || option.disabled) &&
                  "cursor-not-allowed opacity-50",
              )}
            >
              <input
                className="sr-only"
                type={multiple ? "checkbox" : "radio"}
                name={name ?? id}
                value={option.value}
                checked={checked}
                disabled={disabled || option.disabled}
                onChange={() =>
                  onValueChange(
                    multiple
                      ? checked
                        ? value.filter((item) => item !== option.value)
                        : [...value, option.value]
                      : [option.value],
                  )
                }
              />
              {option.image && (
                <span className="relative aspect-video w-full border-b">
                  <NextImage
                    unoptimized
                    fill
                    sizes="300px"
                    src={option.image}
                    alt=""
                    className="object-cover"
                  />
                </span>
              )}
              <span className="flex items-start gap-3 p-4">
                {option.icon && (
                  <span aria-hidden className="mt-0.5 text-primary">
                    {option.icon}
                  </span>
                )}
                <span className="min-w-0 flex-1">
                  <span className="block text-sm font-semibold">
                    {option.label}
                  </span>
                  {option.description && (
                    <span className="mt-1 block text-xs leading-5 text-muted-foreground">
                      {option.description}
                    </span>
                  )}
                </span>
                <span
                  aria-hidden
                  className={cn(
                    "mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border",
                    checked
                      ? "border-primary bg-primary text-primary-foreground"
                      : "border-input",
                  )}
                >
                  {checked && <Check className="size-3" />}
                </span>
              </span>
            </label>
          );
        })}
      </div>
      {!options.length && (
        <p className="text-sm text-muted-foreground">No choices available.</p>
      )}
    </fieldset>
  );
}

API reference

Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.

ChoiceCardGroup props
PropType / DefaultDescription
labelstring

Default: required

Visible fieldset legend.
optionsChoiceCardOption[]

Default: required

Unique value, label, optional description, icon, image URL, and disabled flag.
valuestring[]

Default: required

Controlled selected values; at most one meaningful entry for single mode.
onValueChange(values: string[]) => void

Default: required

Apply user selection.
multipleboolean

Default: false

Use checkboxes rather than radio buttons.
namestring

Default: generated

Shared native form field name.
columns1 | 2 | 3

Default: 2

Responsive columns; narrow screens use one.
disabledboolean

Default: false

Disable all native controls.
classNamestring

Default: —

Extend the container with semantic theme utilities.

Build with these too