Component
Select
<DomSelect>A styled select control with a closed trigger, floating listbox, keyboard navigation, and rich option slots.
Playground
Try every prop live
Select playground
Use DomSelect instead of DomNativeSelect when options need custom rows, metadata, status, or previews.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { reactive } from 'vue';
import { DomSelect } from '@getdom/studio';
const data = reactive({
"modelValue": "pro",
"id": "",
"name": "",
"label": "Plan",
"description": "",
"placeholder": "Select an option",
"required": false,
"disabled": false,
"readOnly": false,
"invalid": false,
"errors": [],
"visible": true,
"validators": [],
"validateOnBlur": true,
"chrome": "field",
"options": [
{
"label": "Starter",
"value": "starter",
"description": "For small projects."
},
{
"label": "Pro",
"value": "pro",
"description": "For growing teams."
},
{
"label": "Enterprise",
"value": "enterprise",
"description": "For regulated work."
}
],
"placement": "bottom",
"align": "left",
"floatingMode": "viewport",
"width": "min-w-[14rem]",
"optionTone": "accent",
"searchable": false,
"searchPlaceholder": "Search options..."
});
</script>
<template>
<DomSelect
v-bind="data"
@update:modelValue="data.modelValue = $event"
/>
</template>Demo
Workspace switcher
The searchable panel opens to the full option list, then filters rich rows as the user types while v-model stores a simple value.
Use DomSelect when options need richer markup than a native select can render.
Selected value: growth
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { computed, ref } from 'vue';
import { DomAvatar, DomBadge, DomSelect, DomStatusPill } from '@getdom/studio';
const workspace = ref('growth');
const workspaces = [
{
value: 'growth',
label: 'Growth workspace',
description: 'Campaigns, experiments, and referral programs.',
initials: 'GW',
src: 'https://images.unsplash.com/photo-1527980965255-d3b416303d12?auto=format&fit=crop&crop=faces&w=96&h=96&q=80',
tone: 'success',
plan: 'Scale',
count: '18 members',
},
{
value: 'platform',
label: 'Platform team',
description: 'API keys, webhooks, usage limits, and internal tools.',
initials: 'PT',
src: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&crop=faces&w=96&h=96&q=80',
tone: 'info',
plan: 'Enterprise',
count: '42 members',
},
{
value: 'support',
label: 'Support operations',
description: 'Ticket routing, SLAs, health alerts, and playbooks.',
initials: 'SO',
src: 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&crop=faces&w=96&h=96&q=80',
tone: 'warning',
plan: 'Business',
count: '11 members',
},
{
value: 'design',
label: 'Design systems',
description: 'Component governance, accessibility review, and release notes.',
initials: 'DS',
src: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&crop=faces&w=96&h=96&q=80',
tone: 'neutral',
plan: 'Team',
count: '9 members',
},
{
value: 'research',
label: 'Research lab',
description: 'Interview notes, insight tagging, and participant panels.',
initials: 'RL',
src: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?auto=format&fit=crop&crop=faces&w=96&h=96&q=80',
tone: 'info',
plan: 'Pro',
count: '6 members',
},
{
value: 'ops',
label: 'Operations hub',
description: 'Vendor reviews, procurement workflows, and launch checklists.',
initials: 'OH',
src: 'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&crop=faces&w=96&h=96&q=80',
tone: 'warning',
plan: 'Scale',
count: '24 members',
},
];
const current = computed(() => workspaces.find((item) => item.value === workspace.value));
</script>
<template>
<div class="grid w-full max-w-md gap-3">
<DomSelect
v-model="workspace"
label="Workspace"
description="Use DomSelect when options need richer markup than a native select can render."
:options="workspaces"
searchable
search-placeholder="Search workspaces..."
width="min-w-[22rem]"
>
<template #value>
<span v-if="current" class="flex min-w-0 items-center gap-2">
<DomAvatar :src="current.src" :name="current.label" :initials="current.initials" size="xs" />
<span class="truncate">{{ current.label }}</span>
<DomStatusPill :tone="current.tone" size="sm">{{ current.plan }}</DomStatusPill>
</span>
</template>
<template #option="{ option, selected }">
<span class="flex items-start gap-3">
<DomAvatar :src="option.src" :name="option.label" :initials="option.initials" size="sm" />
<span class="min-w-0 flex-1">
<span class="flex items-center gap-2">
<span class="truncate font-medium">{{ option.label }}</span>
<DomStatusPill :tone="option.tone" size="sm">{{ option.plan }}</DomStatusPill>
</span>
<span class="mt-1 block text-xs leading-5 text-muted-fg">{{ option.description }}</span>
<span class="mt-2 flex items-center gap-2">
<DomBadge size="sm" tone="neutral">{{ option.count }}</DomBadge>
<DomBadge v-if="selected" size="sm" tone="success">Selected</DomBadge>
</span>
</span>
</span>
</template>
</DomSelect>
<p class="text-xs text-muted-fg">Selected value: <code class="text-canvas-fg">{{ workspace }}</code></p>
</div>
</template>
Demo
Plan picker
Use a rich select for plan, workspace, role, provider, template, and project pickers instead of a native select.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import { DomBadge, DomSelect } from '@getdom/studio';
const plan = ref('pro');
const plans = [
{ value: 'starter', label: 'Starter', description: 'For personal tools and prototypes.', price: '$19', badge: 'Good for trials' },
{ value: 'pro', label: 'Pro', description: 'For small teams shipping customer workflows.', price: '$79', badge: 'Most popular' },
{ value: 'enterprise', label: 'Enterprise', description: 'For SSO, audit logs, governance, and dedicated support.', price: 'Custom', badge: 'Requires sales' },
];
</script>
<template>
<div class="w-full max-w-md">
<DomSelect v-model="plan" label="Plan" :options="plans" width="min-w-[20rem]">
<template #option="{ option, selected }">
<span class="flex items-start justify-between gap-4">
<span class="min-w-0">
<span class="flex items-center gap-2">
<span class="font-semibold">{{ option.label }}</span>
<DomBadge v-if="selected" size="sm" tone="success">Current</DomBadge>
</span>
<span class="mt-1 block text-xs leading-5 text-muted-fg">{{ option.description }}</span>
<DomBadge class="mt-2" size="sm" tone="neutral">{{ option.badge }}</DomBadge>
</span>
<span class="shrink-0 text-sm font-semibold text-canvas-fg">{{ option.price }}</span>
</span>
</template>
</DomSelect>
</div>
</template>
Demo
Grouped content types
Set option.group to add labelled, non-selectable sections with sticky headings. Each description stays hidden in a hover-and-focus popover, while a checkmark distinguishes the committed choice from the active row.
Choose the structure that best matches the reader's intent.
Selected: Guide / How-to
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { computed, ref } from 'vue';
import { DomPopover, DomSelect } from '@getdom/studio';
const selectedType = ref('guide-how-to');
const contentTypes = [
{
id: 'guide-how-to',
typeLabel: 'Guide',
subtypeLabel: 'How-to',
description: 'Step-by-step guidance that helps readers complete a specific task with sequential, testable actions.',
},
{
id: 'guide-explainer',
typeLabel: 'Guide',
subtypeLabel: 'Explainer',
description: 'Educational content that builds understanding of a topic, concept, or process without forcing a step-by-step structure.',
},
{
id: 'guide-comparison',
typeLabel: 'Guide',
subtypeLabel: 'Focused comparison',
description: 'A focused side-by-side analysis of a small number of options for readers making a specific decision.',
},
{
id: 'guide-reference',
typeLabel: 'Guide',
subtypeLabel: 'Reference',
description: 'A scannable lookup resource for definitions, requirements, prices, statistics, benchmarks, or terminology.',
},
{
id: 'listicle-comparison',
typeLabel: 'Listicle',
subtypeLabel: 'Comparison list',
description: 'A multi-option list that evaluates products or services using the same practical criteria.',
},
{
id: 'listicle-round-up',
typeLabel: 'Listicle',
subtypeLabel: 'Round-up',
description: 'A curated collection of tips, recommendations, strategies, or ideas organized around a common theme.',
},
{
id: 'listicle-resources',
typeLabel: 'Listicle',
subtypeLabel: 'Resources',
description: 'A practical collection of external tools, software, templates, websites, or resources readers can use.',
},
{
id: 'listicle-examples',
typeLabel: 'Listicle',
subtypeLabel: 'Examples',
description: 'A showcase of real-world implementations, case studies, samples, or inspirational examples.',
},
];
const options = contentTypes.map(toSelectOption);
const selectedItem = computed(() => contentTypes.find((item) => item.id === selectedType.value));
/**
* Adapt an application-owned content type record to the DomSelect option contract.
*
* @param {{ id: string, typeLabel: string, subtypeLabel: string, description: string }} item Content type record.
* @returns {Object} Select option that retains the original application fields.
*/
function toSelectOption(item) {
return {
...item,
value: item.id,
label: item.subtypeLabel,
group: item.typeLabel,
};
}
</script>
<template>
<div class="grid w-full max-w-xl gap-3">
<DomSelect
v-model="selectedType"
label="Content type"
description="Choose the structure that best matches the reader's intent."
:options="options"
searchable
search-placeholder="Search content types..."
width="w-[min(34rem,calc(100vw-2rem))]"
>
<template #option="{ option, optionId }">
<span class="block font-medium">{{ option.label }}</span>
<DomPopover
:trigger-id="optionId"
trigger="hover"
position="end"
width="w-72 max-w-[calc(100vw-2rem)]"
padding="p-3"
>
<p class="leading-5 text-muted-fg">{{ option.description }}</p>
</DomPopover>
</template>
</DomSelect>
<p class="text-xs text-muted-fg">
Selected: <code class="text-canvas-fg">{{ selectedItem?.typeLabel }} / {{ selectedItem?.subtypeLabel }}</code>
</p>
</div>
</template>
Demo
Contact directory
A 52-contact directory exercises sticky headings from A through Z. Scroll the open list to watch each alphabetical group replace the previous heading.
Scroll through the directory to test the alphabetical sticky headings.
Selected: Aisha Adams
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { computed, ref } from 'vue';
import { DomSelect } from '@getdom/studio';
const selectedContact = ref('aisha-adams');
const contactGroups = [
{ letter: 'A', names: ['Aisha Adams', 'Alina Alvarez'] },
{ letter: 'B', names: ['Benjamin Brooks', 'Bianca Bennett'] },
{ letter: 'C', names: ['Camila Chen', 'Caleb Carter'] },
{ letter: 'D', names: ['Daniel Diaz', 'Daphne Dunn'] },
{ letter: 'E', names: ['Elias Evans', 'Elena Edwards'] },
{ letter: 'F', names: ['Fatima Foster', 'Felix Flores'] },
{ letter: 'G', names: ['Gabriel Green', 'Grace Gupta'] },
{ letter: 'H', names: ['Hana Hughes', 'Hugo Harris'] },
{ letter: 'I', names: ['Imani Ibrahim', 'Isaac Ingram'] },
{ letter: 'J', names: ['Jasmine Jones', 'Jonah Jensen'] },
{ letter: 'K', names: ['Kai Kim', 'Keira Khan'] },
{ letter: 'L', names: ['Lena Lewis', 'Luca Lopez'] },
{ letter: 'M', names: ['Maya Martin', 'Marcus Moore'] },
{ letter: 'N', names: ['Nina Nguyen', 'Noah Nelson'] },
{ letter: 'O', names: ['Olivia Ortiz', 'Omar Owens'] },
{ letter: 'P', names: ['Priya Patel', 'Peter Price'] },
{ letter: 'Q', names: ['Quinn Quade', 'Quentin Quinn'] },
{ letter: 'R', names: ['Rina Rivera', 'Rowan Reed'] },
{ letter: 'S', names: ['Sofia Singh', 'Samuel Scott'] },
{ letter: 'T', names: ['Talia Turner', 'Theo Taylor'] },
{ letter: 'U', names: ['Uma Usman', 'Uri Underwood'] },
{ letter: 'V', names: ['Valeria Vega', 'Victor Vaughn'] },
{ letter: 'W', names: ['Willow Walker', 'Wesley Wong'] },
{ letter: 'X', names: ['Ximena Xu', 'Xavier Xiong'] },
{ letter: 'Y', names: ['Yara Young', 'Yusuf Yilmaz'] },
{ letter: 'Z', names: ['Zara Zhang', 'Zane Zimmerman'] },
];
const roles = [
'Account manager',
'Customer success',
'Design lead',
'Engineering manager',
'Finance partner',
'Operations lead',
'Product manager',
'Support specialist',
];
const contacts = contactGroups.flatMap(buildContactGroup);
const currentContact = computed(() => contacts.find((contact) => contact.value === selectedContact.value));
/**
* Build the selectable contacts for one alphabetical group.
*
* @param {{ letter: string, names: string[] }} group Alphabetical contact group.
* @param {number} groupIndex Position of the group in the directory.
* @returns {Array<Object>} DomSelect option records for the group.
*/
function buildContactGroup(group, groupIndex) {
return group.names.map((name, contactIndex) => buildContact(name, group.letter, groupIndex, contactIndex));
}
/**
* Create a contact option with deterministic role and email metadata.
*
* @param {string} name Contact's display name.
* @param {string} letter Alphabetical group label.
* @param {number} groupIndex Position of the group in the directory.
* @param {number} contactIndex Position of the contact inside the group.
* @returns {Object} Contact option consumed by DomSelect.
*/
function buildContact(name, letter, groupIndex, contactIndex) {
const value = contactValue(name);
return {
value,
label: name,
group: letter,
description: roles[(groupIndex + contactIndex) % roles.length],
email: `${value.replace('-', '.')}@example.com`,
};
}
/**
* Convert a contact name into a stable option value.
*
* @param {string} name Contact's display name.
* @returns {string} Lowercase hyphenated contact identifier.
*/
function contactValue(name) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
</script>
<template>
<div class="grid w-full max-w-lg gap-3">
<DomSelect
v-model="selectedContact"
label="Contact"
description="Scroll through the directory to test the alphabetical sticky headings."
:options="contacts"
searchable
search-placeholder="Search contacts..."
width="w-[min(30rem,calc(100vw-2rem))]"
>
<template #option="{ option }">
<span class="block min-w-0">
<span class="block truncate font-medium">{{ option.label }}</span>
<span class="mt-0.5 block truncate text-xs text-muted-fg">
{{ option.description }} · {{ option.email }}
</span>
</span>
</template>
</DomSelect>
<p class="text-xs text-muted-fg">
Selected: <code class="text-canvas-fg">{{ currentContact?.label }}</code>
</p>
</div>
</template>
Demo
Optional value
Include an option with value '' when the field should support an explicit unset state.
Selected value: (empty)
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import { DomSelect, DomStatusPill } from '@getdom/studio';
const assignee = ref('');
const people = [
{ value: '', label: 'Not assigned', description: 'Leave this workflow unowned.' },
{ value: 'ada', label: 'Ada Lovelace', description: 'Product analytics' },
{ value: 'grace', label: 'Grace Hopper', description: 'Platform engineering' },
{ value: 'katherine', label: 'Katherine Johnson', description: 'Launch readiness' },
];
</script>
<template>
<div class="grid w-full max-w-sm gap-3">
<DomSelect
v-model="assignee"
label="Assignee"
placeholder="Choose a person"
:options="people"
width="min-w-[18rem]"
>
<template #option="{ option, selected }">
<span class="flex items-start justify-between gap-3">
<span class="min-w-0">
<span class="font-medium">{{ option.label }}</span>
<span class="mt-1 block text-xs leading-5 text-muted-fg">{{ option.description }}</span>
</span>
<DomStatusPill v-if="selected" size="sm" tone="success">Selected</DomStatusPill>
</span>
</template>
</DomSelect>
<p class="text-xs text-muted-fg">Selected value: <code class="text-canvas-fg">{{ assignee || '(empty)' }}</code></p>
</div>
</template>
Reference
Props
Control props
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
modelValue | string | number | string | '' | Selected option value. |
placeholder | string | string | 'Select an option' | Text shown when no option is selected. |
options*ts | array | Array< | — | Available options. Set group for non-selectable section headings and use the option slot for rich rows. |
placement | 'bottom' | 'top' | 'right' | 'left' | string | 'bottom' | Preferred side before collision handling. |
align | 'left' | 'right' | string | 'left' | Horizontal panel alignment. |
floatingMode | 'viewport' | 'anchor' | string | 'viewport' | viewport keeps the list inside the browser; anchor keeps it attached while scrolling. |
width | string | string | 'min-w-[14rem]' | Tailwind width utility for the floating listbox. |
optionTone | string | string | 'accent' | Theme token basename for hovered, focused, and selected options. For example, primary uses var(--primary) and var(--primary-fg). |
searchable | boolean | string | string | false | Show a search input inside the option panel while keeping select-style committed values. |
searchPlaceholder | string | string | 'Search options...' | Placeholder shown inside the panel search input. |
Field props
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
id | string | string | '' | Optional ID override. By default parent forms derive the input ID from the field path using underscores. |
name | string | string | '' | Local field name. Parent forms derive the full field path and native HTML name from the form hierarchy. |
label | string | string | '' | Visible field label. |
description | string | string | '' | Optional helper copy below the field. |
required | boolean | boolean | false | Mark the field as required. |
disabled | boolean | boolean | false | Disable field interaction. |
readOnly | boolean | boolean | false | Show the value but prevent editing. |
invalid | boolean | boolean | false | Mark the field invalid. |
errorsts | array | object | string | Array< | [] | Validation errors for this field. |
visible | boolean | boolean | true | Show or hide the field. |
validators | array | Array<unknown> | [] | Validators attached to this field. Use functions in Vue code, or serializable records such as { name: "minLength", props: { min: 2 } } in generated schemas. |
validateOnBlur | boolean | boolean | true | Run validators when the field loses focus. |
chrome | 'field' | 'none' | false | string | 'field' | Render default field chrome, or hide chrome while keeping form state wiring. |
Auto-generated from Select.props and inline _edit hints.
Slots
| Name | Scope | Description |
|---|---|---|
| #value | { option, value, label, placeholder } | Custom selected-value markup inside the trigger. |
| #option | { option, index, selected, optionId } | Custom option markup inside the floating list. optionId identifies the focusable option button for external floating UI. |
| #group | { group, options, index } | Custom heading markup for options that share a group. |
Events
| Name | Payload | Description |
|---|---|---|
| @update:modelValue | ( | Emitted when the selected option changes. |
| @select | ({ option, value, label }) | Emitted with the selected option record. |
| @focus | — | — |
| @blur | — | — |
Names auto-detected from defineEmits and source emit() calls; payload and description from __doc.events when present.
Keyboard
- Enter / Space / ↓Open the listbox.
- ↑ / ↓Move active option.
- Type in searchFilter visible options when searchable is enabled.
- Enter / SpaceSelect active option.
- Esc / TabClose and return focus.