ChoiceCardGroup
FormImage and icon choices using native single- or multiple-selection form controls.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { ChoiceCardGroup } from "@/components/ui/choice-card-group";
import { Bell, Mail, Smartphone } from "lucide-react";
import { useState } from "react";
export default function ChoiceCardGroupExample() {
const [theme, setTheme] = useState(["coast"]);
const [channels, setChannels] = useState(["email"]);
return (
<div className="w-full max-w-2xl space-y-8">
<ChoiceCardGroup
label="Choose your workspace cover"
value={theme}
onValueChange={setTheme}
name="cover"
options={[
{
value: "coast",
label: "Coastal calm",
description: "A little room to breathe.",
image: "/images/component-demos/coast.svg",
},
{
value: "summit",
label: "Higher ground",
description: "A fresh perspective on your work.",
image: "/images/component-demos/summit.svg",
},
]}
/>
<ChoiceCardGroup
label="Notification channels"
value={channels}
onValueChange={setChannels}
multiple
columns={3}
name="channels"
options={[
{
value: "email",
label: "Email",
description: "A considered daily summary.",
icon: <Mail className="size-5" />,
},
{
value: "push",
label: "Push",
description: "Updates as they happen.",
icon: <Bell className="size-5" />,
},
{
value: "sms",
label: "SMS",
description: "Available on the Team plan.",
icon: <Smartphone className="size-5" />,
disabled: true,
},
]}
/>
<p role="status" className="text-xs text-muted-foreground">
Cover: {theme[0] ?? "none"}. Channels: {channels.join(", ") || "none"}.
</p>
</div>
);
}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 { 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.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Visible fieldset legend. |
| options | ChoiceCardOption[]Default: required | Unique value, label, optional description, icon, image URL, and disabled flag. |
| value | string[]Default: required | Controlled selected values; at most one meaningful entry for single mode. |
| onValueChange | (values: string[]) => voidDefault: required | Apply user selection. |
| multiple | booleanDefault: false | Use checkboxes rather than radio buttons. |
| name | stringDefault: generated | Shared native form field name. |
| columns | 1 | 2 | 3Default: 2 | Responsive columns; narrow screens use one. |
| disabled | booleanDefault: false | Disable all native controls. |
| className | stringDefault: — | Extend the container with semantic theme utilities. |