One component, two variants — pass `type="cards"` for bordered rows with thumbnails or `type="chip-group"` for compact h-9 pills.
Pick the design services you need
Your selections
1 of 6Sermon Design
Installation
$Terminal
npx shadcn@latest add https://sdk-components.thesqd.com/r/multi-select.jsonUsage
Bind a controlled `string[]` to `value`/`onChange`. Both variants ship from the same `radio-group` file.
TSImport
import { MultiSelect } from "@/components/ui/multi-select";TSExample
import * as React from "react";
import { MultiSelect } from "@/components/ui/multi-select";
const PRODUCTS = [
{ value: "sermon-design", title: "Sermon Design", caption: "Slides + graphics for your weekly series.", image: "https://picsum.photos/seed/sermon/112" },
{ value: "social-media", title: "Social Media", caption: "Templates tuned for IG / TikTok / etc.", image: "https://picsum.photos/seed/social/112" },
{ value: "event-design", title: "Event Design", caption: "Visual branding for events.", image: "https://picsum.photos/seed/event/112" },
];
export function Example() {
const [value, setValue] = React.useState<string[]>([]);
return (
<MultiSelect
type="cards"
aria-label="Design services"
value={value}
onChange={setValue}
options={PRODUCTS}
/>
);
}Composition
Anatomy of MultiSelectCardGroup + MultiSelectChipGroup.
MultiSelect
├── type="cards" | "chip-group" (variant discriminator)
├── options (shape depends on `type`)
│ ├── cards → { value, title, caption?, image?, swatch?, lastUsed?, badge?, disabled? }
│ └── chip-group → { value, label, disabled? }
├── value / defaultValue (string[] — works the same for both types)
├── onChange ((next: string[]) => void)
├── className (override the layout — chip-group defaults to grid-cols-3, cards to sm:grid-cols-2)
└── aria-label (group accessible label)Cards
`type="cards"` — bordered rows with swatch + title + description and a `+` / ✓ toggle in the corner.
Pick the design services you need
Your selections
1 of 6Sermon Design
Cards with “Last used” badge
Pass `lastUsed: true` on any card option to float a built-in amber “Last used” pill on its top border — no `<Badge>` import or JSX needed. Mirrors the file-size picker's tag.
Pick the design services you need
Cards with detail field
Flag any card option with `requiresDetail: true` (plus an optional `detailLabel`) and selecting it reveals a required textarea nested directly beneath it — indented and tied back with an elbow connector. A lone field spans full width; two in the same row sit side-by-side under their cards. Pass `detailValues` / `onDetailChange` to read the text (a `value → text` map). Only honored at `columns` 1 or 2.
What kind of reel are you requesting?
Cards with sections
Pass `sections={[{ key, label, icon }]}` and tag each option with `section: "<key>"` to group them under FeaturedIcon headers separated by a hairline divider. Use `columns` (1–4) to set the grid width.
Pick the services you need
Design Projects
Video Projects
Chip group
`type="chip-group"` — compact wrap-friendly chips at h-9 (matches Input). Pass `className="flex flex-wrap gap-2"` for a wrap layout instead of the default 3-column grid.
Chip group with “Other”
Pass `otherOption` for a trailing chip that morphs into an inline write-in input the moment it's toggled on — it stays in the chip flow instead of dropping a field below. Press Esc to revert it to a chip.
Chip group that wraps
Pass `wrap` to flow chips in an inline row where each chip is sized to its own label and wraps to the next line as needed, instead of the fixed 3-column grid. Combine with `otherOption` for a trailing chip that turns into an inline input.
Chip group — fixed columns
Pass `columns` (1–4) for a fixed grid of equal-width chips instead of `wrap`. In columns mode the checkbox pins to the left and the label centers; the inline `otherOption` fills its column when selected.
API Reference
Props on each multi-select variant + their option shapes.
MultiSelect
| Prop | Type | Default | Description |
|---|---|---|---|
type | "cards" | "chip-group" | — | Selects the variant. `cards` renders bordered rows with a swatch + title + description and a `+` / ✓ toggle in the corner. `chip-group` renders compact h-9 pills. |
options | MultiSelectCardOption[] | MultiSelectChipOption[] | — | Items to render — shape depends on `type`. |
value | string[] | — | Controlled selected values. |
defaultValue | string[] | — | Uncontrolled initial values. |
onChange | (next: string[]) => void | — | Selection change handler. |
aria-label | string | — | Accessible label for the group. |
className | string | — | Override the layout. `cards` defaults to `sm:grid-cols-2`. `chip-group` defaults to `grid grid-cols-3` — pass `flex flex-wrap gap-2` for a wrap layout. |
wrap | boolean | false | Chip-group only. Flow chips in a wrapping inline row sized to each label instead of a fixed grid. Wins over `columns`. |
columns | 1 | 2 | 3 | 4 | cards: 2 · chips: 3 | Number of equal-width columns. Cards apply it at the `sm:` breakpoint; chip-group applies it at all sizes (ignored when `wrap` is set). |
sections | { key, label, icon? }[] | — | Cards only. When provided, options group by `option.section === section.key`. Each header renders a FeaturedIcon + label + hairline divider. Orphan options fall into a trailing implicit "Other" group. |
detailValues | Record<string, string> | — | Cards only. Controlled `option.value → detail text` map for options flagged `requiresDetail`. |
defaultDetailValues | Record<string, string> | — | Cards only. Uncontrolled initial detail-text map. |
onDetailChange | (next: Record<string, string>) => void | — | Cards only. Fires when any revealed detail textarea changes. |
MultiSelectCardOption (when type="cards")
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Stable value for this option. |
title | ReactNode | — | Primary label rendered next to the swatch/image. |
caption | ReactNode | — | Optional muted helper line under the title. |
image | string | — | Image URL rendered as the leading thumbnail. `.mp4`/`.webm`/`.mov`/`.m4v`/`.ogv` swap to a muted/autoplay/loop `<video>`. |
imageAlt | string | — | Alt text for `image`. |
swatch | ReactNode | — | Custom JSX (gradient, icon, …) — wins over `image`. |
lastUsed | boolean | false | Renders a built-in amber "Last used" pill floated on the card's top border. Pass `lastUsed: true` — no `<Badge>` import needed. |
badge | ReactNode | — | Optional custom pill floated on the card's top border. Renders alongside the built-in `lastUsed` pill when both are set. |
section | string | — | Group key matching a `sections[*].key`. Only used when `sections` is set. |
requiresDetail | boolean | false | Selecting this card reveals a required textarea nested beneath it, tied back with an elbow connector. Only honored at `columns` 1 or 2. |
detailLabel | ReactNode | — | Label above the revealed detail textarea. Defaults to "Provide more details". |
detailPlaceholder | string | — | Placeholder for the revealed detail textarea. |
disabled | boolean | false | Disable this card. |
MultiSelectChipOption (when type="chip-group")
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Stable value for this option. |
label | ReactNode | — | Visible chip label. |
disabled | boolean | false | Disable this chip. |