Multi select

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 6
Sermon Design
Installation
$Terminal
npx shadcn@latest add https://sdk-components.thesqd.com/r/multi-select.json
Usage
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 6
Sermon 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

PropTypeDefaultDescription
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.
optionsMultiSelectCardOption[] | MultiSelectChipOption[]Items to render — shape depends on `type`.
valuestring[]Controlled selected values.
defaultValuestring[]Uncontrolled initial values.
onChange(next: string[]) => voidSelection change handler.
aria-labelstringAccessible label for the group.
classNamestringOverride 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.
wrapbooleanfalseChip-group only. Flow chips in a wrapping inline row sized to each label instead of a fixed grid. Wins over `columns`.
columns1 | 2 | 3 | 4cards: 2 · chips: 3Number 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.
detailValuesRecord<string, string>Cards only. Controlled `option.value → detail text` map for options flagged `requiresDetail`.
defaultDetailValuesRecord<string, string>Cards only. Uncontrolled initial detail-text map.
onDetailChange(next: Record<string, string>) => voidCards only. Fires when any revealed detail textarea changes.

MultiSelectCardOption (when type="cards")

PropTypeDefaultDescription
valuestringStable value for this option.
titleReactNodePrimary label rendered next to the swatch/image.
captionReactNodeOptional muted helper line under the title.
imagestringImage URL rendered as the leading thumbnail. `.mp4`/`.webm`/`.mov`/`.m4v`/`.ogv` swap to a muted/autoplay/loop `<video>`.
imageAltstringAlt text for `image`.
swatchReactNodeCustom JSX (gradient, icon, …) — wins over `image`.
lastUsedbooleanfalseRenders a built-in amber "Last used" pill floated on the card's top border. Pass `lastUsed: true` — no `<Badge>` import needed.
badgeReactNodeOptional custom pill floated on the card's top border. Renders alongside the built-in `lastUsed` pill when both are set.
sectionstringGroup key matching a `sections[*].key`. Only used when `sections` is set.
requiresDetailbooleanfalseSelecting this card reveals a required textarea nested beneath it, tied back with an elbow connector. Only honored at `columns` 1 or 2.
detailLabelReactNodeLabel above the revealed detail textarea. Defaults to "Provide more details".
detailPlaceholderstringPlaceholder for the revealed detail textarea.
disabledbooleanfalseDisable this card.

MultiSelectChipOption (when type="chip-group")

PropTypeDefaultDescription
valuestringStable value for this option.
labelReactNodeVisible chip label.
disabledbooleanfalseDisable this chip.