Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Comments
  • Discussion
  • Suggestions
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Details
  • Marks
    • Bold
    • Italic
    • Underline
    • Code
    • Highlight
    • Keyboard Input
    • Strikethrough
    • Subscript
    • Superscript
      • Font
      • Line Height
      • Text Align
    • Indent
    • List
      • Exit Break
      • Single Block
      • Trailing Block
    • Autoformat
    • Block Menu
    • Block Placeholder
    • Combobox
      • Emoji
      • MentionElement
      • Slash Command
    • Drag & Drop
    • Navigation Feedback
    • Linting
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • DOCX
    • HTML
    • Markdown

Multi Select

PreviousNext

A rich multi-select editor.

Select Editor
Loading…
YjsDOCX

On This Page

InstallationUsageExamplesFormBehaviorAPI ReferenceSelectEditorSelectItem
Build your editor
Production-ready AI template and reusable components.
Get all-access

SelectEditor is a one-line editor whose value is a list of chips. Each picked label is an inline chip, so the caret moves between chips, Backspace removes one and chips copy and paste like text. The menu filters items by the typed search.

Installation

npx shadcn@latest add @plate/select-editor

Installing the item adds two files: select-editor.tsx, with the field and its tag rules, and tag.tsx, with the tag element and its chip.

Usage

import * as React from 'react';
 
import { type SelectItem, SelectEditor } from '@/components/editor/select-editor';
 
const ITEMS: SelectItem[] = [
  { value: 'React' },
  { value: 'TypeScript' },
  { value: 'JavaScript' },
];
 
export function LabelsField() {
  const [value, setValue] = React.useState<SelectItem[]>([ITEMS[0]]);
 
  return (
    <SelectEditor
      aria-label="Labels"
      items={ITEMS}
      placeholder="Select labels..."
      value={value}
      onValueChange={setValue}
    />
  );
}
import * as React from 'react';
 
import { type SelectItem, SelectEditor } from '@/components/editor/select-editor';
 
const ITEMS: SelectItem[] = [
  { value: 'React' },
  { value: 'TypeScript' },
  { value: 'JavaScript' },
];
 
export function LabelsField() {
  const [value, setValue] = React.useState<SelectItem[]>([ITEMS[0]]);
 
  return (
    <SelectEditor







onValueChange must update value synchronously. The field writes value back whenever it differs from the chips, so a value set later, such as in a transition, first reverts the change. Pass defaultValue instead of value for an uncontrolled field.

Examples

Form

The field takes a form library's ref, onBlur and ARIA props, and passes them to the editable.

'use client';
 
import { zodResolver } from '@hookform/resolvers/zod';
import { CheckIcon, PlusIcon } from 'lucide-react';
import * as React from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
 
import { Button } from '@/components/ui/button';
import {
  type SelectItem,
  SelectEditor,
} from '@/components/editor/select-editor';
 
const LABELS = [
  { url: '/docs/components/editor', value: 'Editor' },
  { url: '/docs/components/select-editor', value: 'Select Editor' },
  { url: '/docs/api/selection', value: 'Node Selection' },
  { url: '/docs/components/button', value: 'Button' },
  { url: '/docs/components/command', value: 'Command' },
  { url: '/docs/components/dialog', value: 'Dialog' },
  { url: '/docs/components/field', value: 'Field' },
  { url: '/docs/components/input', value: 'Input' },
  { url: '/docs/components/label', value: 'Label' },
  { url: '/docs/components/popover', value: 'Popover' },
] satisfies Array<SelectItem & { url: string }>;
 
const formSchema = z.object({
  labels: z
    .array(
      z.object({
        url: z.string().optional(),
        value: z.string(),
      })
    )
    .min(1, 'Select at least one label')
    .max(10, 'Select up to 10 labels'),
});
 
type FormValues = z.infer<typeof formSchema>;
 
export default function EditorSelectForm() {
  const [readOnly, setReadOnly] = React.useState(false);
  const form = useForm<FormValues>({
    defaultValues: {
      labels: [LABELS[0]],
    },
    resolver: zodResolver(formSchema),
  });
 
  const labels = useWatch({ control: form.control, name: 'labels' });
  const errorId = React.useId();
 
  return (
    <div className="mx-auto w-full max-w-2xl space-y-8 p-11 pt-24 pl-2">
      <div className="space-y-6">
        <Controller
          name="labels"
          control={form.control}
          render={({ field, fieldState }) => (
            <div data-invalid={fieldState.invalid}>
              <div className="flex items-start gap-2">
                <Button
                  variant="ghost"
                  className="h-10"
                  onClick={() => {
                    setReadOnly(!readOnly);
                  }}
                  type="button"
                >
                  {readOnly ? (
                    <PlusIcon className="size-4" />
                  ) : (
                    <CheckIcon className="size-4" />
                  )}
                </Button>
 
                {readOnly && labels.length === 0 ? (
                  <Button
                    size="lg"
                    variant="ghost"
                    className="h-10"
                    onClick={() => {
                      setReadOnly(false);
                    }}
                    type="button"
                  >
                    Add labels
                  </Button>
                ) : (
                  <SelectEditor
                    ref={field.ref}
                    aria-describedby={fieldState.error ? errorId : undefined}
                    aria-invalid={fieldState.invalid}
                    aria-label="Labels"
                    items={LABELS}
                    placeholder={readOnly ? 'Empty' : 'Select labels...'}
                    readOnly={readOnly}
                    value={field.value}
                    onBlur={field.onBlur}
                    onValueChange={readOnly ? undefined : field.onChange}
                  />
                )}
              </div>
              {fieldState.error?.message ? (
                <p
                  id={errorId}
                  className="text-sm text-destructive"
                  role="alert"
                >
                  {fieldState.error.message}
                </p>
              ) : null}
            </div>
          )}
        />
      </div>
    </div>
  );
}
'use client';
 
import { zodResolver } from '@hookform/resolvers/zod';
import { CheckIcon, PlusIcon } from 'lucide-react';
import * as React from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
 
import { Button } from '@/components/ui/button';
import {
  type SelectItem,
  SelectEditor,
} from '@/components/editor/select-editor';
 
const LABELS = [
  { url: '/docs/components/editor', value: 







































































































Behavior

  • Type to search. ArrowUp and ArrowDown move the active option, and Enter picks it.
  • A search of two or more characters that matches no label offers to create it.
  • Escape, mod+Enter or a click outside closes the menu and clears the search.
  • Labels that differ only in case count as one. The first chip wins, so a form value that repeats a label shows it once.
  • Undo and redo keys are off, so a picked chip stays.
  • A read-only field renders a chip as a link when getLinkHref accepts its url. A chip whose url it refuses, such as a javascript: address or a bare https://, shows without a link.
  • Dragging a chip before another chip moves it there. Dropping a chip after another chip does nothing.

Edit SelectTagPlugin in the copied select-editor.tsx to change these rules, and the field's key handler to change the keys.

API Reference

SelectEditor

Takes the editable's props, such as placeholder, readOnly, onBlur and aria-*, except the combobox attributes the field sets itself.

Props

    The labels the menu offers.

    The chips of a controlled field.

    The starting chips of an uncontrolled field.

    Called with the chips after each change made in the field.

SelectItem

Attributes

    The label.

    The link a read-only chip renders when getLinkHref accepts it.

aria-label="Labels"
items={ITEMS}
placeholder="Select labels..."
value={value}
onValueChange={setValue}
/>
);
}
'Editor'
},
{ url: '/docs/components/select-editor', value: 'Select Editor' },
{ url: '/docs/api/selection', value: 'Node Selection' },
{ url: '/docs/components/button', value: 'Button' },
{ url: '/docs/components/command', value: 'Command' },
{ url: '/docs/components/dialog', value: 'Dialog' },
{ url: '/docs/components/field', value: 'Field' },
{ url: '/docs/components/input', value: 'Input' },
{ url: '/docs/components/label', value: 'Label' },
{ url: '/docs/components/popover', value: 'Popover' },
] satisfies Array<SelectItem & { url: string }>;
const formSchema = z.object({
labels: z
.array(
z.object({
url: z.string().optional(),
value: z.string(),
})
)
.min(1, 'Select at least one label')
.max(10, 'Select up to 10 labels'),
});
type FormValues = z.infer<typeof formSchema>;
export default function EditorSelectForm() {
const [readOnly, setReadOnly] = React.useState(false);
const form = useForm<FormValues>({
defaultValues: {
labels: [LABELS[0]],
},
resolver: zodResolver(formSchema),
});
const labels = useWatch({ control: form.control, name: 'labels' });
const errorId = React.useId();
return (
<div className="mx-auto w-full max-w-2xl space-y-8 p-11 pt-24 pl-2">
<div className="space-y-6">
<Controller
name="labels"
control={form.control}
render={({ field, fieldState }) => (
<div data-invalid={fieldState.invalid}>
<div className="flex items-start gap-2">
<Button
variant="ghost"
className="h-10"
onClick={() => {
setReadOnly(!readOnly);
}}
type="button"
>
{readOnly ? (
<PlusIcon className="size-4" />
) : (
<CheckIcon className="size-4" />
)}
</Button>
{readOnly && labels.length === 0 ? (
<Button
size="lg"
variant="ghost"
className="h-10"
onClick={() => {
setReadOnly(false);
}}
type="button"
>
Add labels
</Button>
) : (
<SelectEditor
ref={field.ref}
aria-describedby={fieldState.error ? errorId : undefined}
aria-invalid={fieldState.invalid}
aria-label="Labels"
items={LABELS}
placeholder={readOnly ? 'Empty' : 'Select labels...'}
readOnly={readOnly}
value={field.value}
onBlur={field.onBlur}
onValueChange={readOnly ? undefined : field.onChange}
/>
)}
</div>
{fieldState.error?.message ? (
<p
id={errorId}
className="text-sm text-destructive"
role="alert"
>
{fieldState.error.message}
</p>
) : null}
</div>
)}
/>
</div>
</div>
);
}