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?

Image tiles
`layout="tile"` on its own: the image is the whole card, with a `+` / ✓ over its corner. Leave `requiresDetail` off the options and picking a tile just checks it — no field opens.

Which of these styles feels right?

Bento tiles
Add `bento` to `layout="tile"` for images of every shape: each keeps its own aspect ratio, tiles stack into the shortest column, and the columns are balanced to end on one line — no holes, and only a sliver of crop. Pass `aspectRatio` on an option when you know it (the grid is right on the first frame); otherwise the image is measured as it loads.

Which of these feels right?

Image tiles with a note
Pass `layout="tile"` and the image becomes the card. Flag options `requiresDetail` and picking one grows a note field (with a dictation mic — `dictation={false}` hides it) out of the tile's bottom edge — a card-resize tween, no second box. Only the open tile grows; its row-mates keep their height. Read the notes back through `detailValues` / `onDetailChange`.

Which of these styles feels right?

Pick the ones you like, and say what you like about each — that's what your designer reads.

Output

{
  "value": [
    "minimal",
    "collage"
  ],
  "detailValues": {}
}
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[]) => void—Selection change handler.
aria-labelstring—Accessible label for the group.
classNamestring—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.
wrapbooleanfalseChip-group only. Flow chips in a wrapping inline row sized to each label instead of a fixed grid. Wins over `columns`.
layout"row" | "tile""row"Cards only. `tile` makes the image the whole card (16:9, `+` / ✓ over its corner) and grows any `requiresDetail` field out of the tile's bottom edge, at any `columns`.
bentobooleanfalse`layout="tile"` only. A bento grid for images of mixed shapes: every image keeps its own aspect ratio at column width, tiles go to the shortest column, and each column is scaled a few percent to one height — a flush rectangle with no holes. Arrow keys move to the nearest tile on screen; one column on a phone. The shape is the option's `aspectRatio`, or measured as the image loads. Ignored when any option `requiresDetail`.
indicator"plus" | "radio""plus"Cards only. The corner mark on each card: a `+` / ✓ button, or a radio dot for single-choice pickers (RadioGroup `type="image"` uses `radio`).
columns1 | 2 | 3 | 4cards: 2 · tiles: 3 · 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.
dictationbooleantrueCards only. Show the mic (browser dictation) on revealed detail fields. Pass `false` to hide it.
onDetailChange(next: Record<string, string>) => void—Cards only. Fires when any revealed detail textarea changes.

MultiSelectCardOption (when type="cards")

PropTypeDefaultDescription
valuestring—Stable value for this option.
titleReactNode—Primary label rendered next to the swatch/image.
captionReactNode—Optional muted helper line under the title.
imagestring—Image URL rendered as the leading thumbnail. `.mp4`/`.webm`/`.mov`/`.m4v`/`.ogv` swap to a muted/autoplay/loop `<video>`.
imageAltstring—Alt text for `image`.
aspectRationumber—The image's width ÷ height, for `bento` tiles. Left out, the tile measures its image as it loads (a plain 16:9 cell until then).
swatchReactNode—Custom 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.
badgeReactNode—Optional custom pill floated on the card's top border. Renders alongside the built-in `lastUsed` pill when both are set.
sectionstring—Group 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 — except with `layout="tile"`, where the field grows inside the tile.
detailLabelReactNode—Label above the revealed detail textarea. Defaults to "Provide more details".
detailPlaceholderstring—Placeholder for the revealed detail textarea.
disabledbooleanfalseDisable this card.

MultiSelectChipOption (when type="chip-group")

PropTypeDefaultDescription
valuestring—Stable value for this option.
labelReactNode—Visible chip label.
disabledbooleanfalseDisable this chip.