Select
Pick one value from a list.
Melt UI Select
<script lang="ts">
import * as Select from '#lib/components/ui/select/index.js';
const fruits = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'blueberry', label: 'Blueberry' },
{ value: 'grapes', label: 'Grapes', disabled: true },
{ value: 'pineapple', label: 'Pineapple' }
];
let fruit = $state('');
</script>
<Select.Root bind:value={fruit}>
<Select.Label>Favorite fruit</Select.Label>
<Select.Trigger style="--dui-select-trigger-width: 11rem">
<Select.Value placeholder="Select a fruit" />
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.GroupHeading>Fruits</Select.GroupHeading>
{#each fruits as f (f.value)}
<Select.Item value={f.value} label={f.label} disabled={f.disabled} />
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
Installation
npx distill-ui add selectIt installs @floating-ui/domandmelt if you don't have them yet.
- Set up the tokens once, if you haven't: see manual installation.
- Install the packages it needs:
npm install @floating-ui/dom@^1.6.0 melt@0.44.0 - Copy these files into your project:
import { getContext, setContext } from 'svelte';
import type { Select } from 'melt/builders';
// One Melt builder per select, shared by its parts. Melt doesn't link the list to a label,
// so Label records its id here for the content to point aria-labelledby at.
export type SelectContext = { select: Select<string>; labelId: string; hasLabel: boolean };
const key = Symbol('distill-select');
export const setSelectContext = (ctx: SelectContext) => setContext(key, ctx);
export const getSelectContext = () => getContext<SelectContext>(key);
import Content from './select-content.svelte';
import GroupHeading from './select-group-heading.svelte';
import Group from './select-group.svelte';
import Item from './select-item.svelte';
import Label from './select-label.svelte';
import Separator from './select-separator.svelte';
import Trigger from './select-trigger.svelte';
import Value from './select-value.svelte';
import Root from './select.svelte';
export { Root, Label, Trigger, Value, Content, Item, Group, GroupHeading, Separator };
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { getSelectContext } from './context.js';
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
const ctx = getSelectContext();
</script>
<!-- Melt uses the native popover API (top layer), so no portal is needed. -->
<div
{...restProps}
{...ctx.select.content}
aria-labelledby={ctx.hasLabel ? ctx.labelId : undefined}
class={['content', className]}
>
<div class="viewport">
{@render children?.()}
</div>
</div>
<!--
Options you can set from a parent or on the select content itself:
--dui-select-content-min-width, --dui-select-content-radius, --dui-select-content-bg, --dui-select-content-fg
-->
<style>
/* Melt positions with floating-ui and the popover API; open animation uses @starting-style
because the popover goes from display:none, and Melt has no exit-animation hook here. */
.content {
margin: 0;
padding: 0;
border: 0;
min-width: max(var(--dui-select-content-min-width, 9rem), var(--melt-invoker-width));
max-height: var(--melt-popover-available-height);
overflow-x: hidden;
outline: none;
overflow-y: auto;
border-radius: var(--dui-select-content-radius, var(--dui-radius-md));
background: var(--dui-select-content-bg, var(--dui-color-popover));
color: var(--dui-select-content-fg, var(--dui-color-popover-foreground));
box-shadow:
0 0 0 1px color-mix(in oklch, var(--dui-color-foreground) 10%, transparent),
var(--dui-shadow-md);
transition:
opacity var(--dui-duration-fast) var(--dui-ease-out),
scale var(--dui-duration-fast) var(--dui-ease-out);
@starting-style {
opacity: 0;
scale: 0.95;
}
}
.viewport {
padding: var(--dui-space-1);
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div class={['heading', className]} {...restProps}>{@render children?.()}</div>
<style>
.heading {
padding: var(--dui-space-1-5) var(--dui-space-2);
color: var(--dui-color-muted-foreground);
font-size: var(--dui-text-xs);
line-height: var(--dui-text-xs-line-height);
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
// Melt has no group part; a plain role="group" div stands in.
let { children, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div role="group" {...restProps}>{@render children?.()}</div>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { getSelectContext } from './context.js';
let {
class: className,
value,
label,
disabled = false,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
value: string;
label?: string;
disabled?: boolean;
} = $props();
const { select } = getSelectContext();
// Melt has no disabled options. It finds options by their data-melt-select-option marker,
// so leaving the marker off takes a disabled item out of arrow keys, Home/End and typeahead,
// and dropping its click and hover handlers means it can't be picked with the mouse either.
const option = $derived.by(() => {
// Melt points aria-activedescendant at getOptionId(value) but doesn't put that id on
// the option, so we add it.
const props: Record<string, unknown> = {
...select.getOption(value, label),
id: select.getOptionId(value)
};
if (disabled) {
delete props['data-melt-select-option'];
delete props.onclick;
delete props.onmouseover;
}
return props;
});
</script>
<div
{...restProps}
{...option}
class={['item', className]}
aria-disabled={disabled || undefined}
data-disabled={disabled ? '' : undefined}
>
<span class="text">{label ?? value}</span>
{#if select.isSelected(value)}
<svg class="indicator" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20 6 9 17l-5-5" />
</svg>
{/if}
</div>
<!--
Options you can set from a parent or on the select item itself:
--dui-select-item-highlight-bg, --dui-select-item-highlight-fg
-->
<style>
.item {
position: relative;
display: flex;
align-items: center;
gap: var(--dui-space-2);
width: 100%;
padding-block: var(--dui-space-1-5);
padding-inline: var(--dui-space-2) var(--dui-space-8);
border-radius: var(--dui-radius-sm);
font-size: var(--dui-text-sm);
line-height: var(--dui-text-sm-line-height);
cursor: default;
user-select: none;
outline: none;
&[data-highlighted] {
background: var(--dui-select-item-highlight-bg, var(--dui-color-accent));
color: var(--dui-select-item-highlight-fg, var(--dui-color-accent-foreground));
}
&[data-disabled] {
pointer-events: none;
opacity: 0.5;
}
}
.text {
display: flex;
flex: 1;
align-items: center;
gap: var(--dui-space-2);
white-space: nowrap;
}
.indicator {
position: absolute;
right: var(--dui-space-2);
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
</style>
<script lang="ts">
import type { HTMLLabelAttributes } from 'svelte/elements';
import { getSelectContext } from './context.js';
let { class: className, children, ...restProps }: HTMLLabelAttributes = $props();
const ctx = getSelectContext();
ctx.hasLabel = true;
</script>
<label {...restProps} {...ctx.select.label} id={ctx.labelId} class={['label', className]}>
{@render children?.()}
</label>
<style>
.label {
display: block;
margin-bottom: var(--dui-space-2);
font-size: var(--dui-text-sm);
line-height: var(--dui-text-sm-line-height);
font-weight: var(--dui-font-weight-medium);
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
...restProps
}: HTMLAttributes<HTMLDivElement> & { ref?: HTMLDivElement | null } = $props();
</script>
<div bind:this={ref} role="separator" class={['separator', className]} {...restProps}></div>
<style>
.separator {
height: 1px;
margin: var(--dui-space-1) calc(var(--dui-space-1) * -1);
background: var(--dui-color-border);
}
</style>
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
import { getSelectContext } from './context.js';
let {
class: className,
style,
children,
size = 'md',
...restProps
}: HTMLButtonAttributes & { size?: 'sm' | 'md' } = $props();
const ctx = getSelectContext();
const select = $derived(ctx.select);
</script>
<!-- Melt sets its own inline style on the trigger (position variables), so a style you pass
is appended to it rather than replacing it. -->
<button
type="button"
{...restProps}
{...select.trigger}
style="{select.trigger.style}; {style ?? ''}"
class={['trigger', className]}
data-size={size}
data-placeholder={select.value ? undefined : ''}
>
{@render children?.()}
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</button>
<!--
Options you can set from a parent or on the select trigger itself:
--dui-select-trigger-width, --dui-select-trigger-height, --dui-select-trigger-border, --dui-select-trigger-radius, --dui-select-trigger-bg
-->
<style>
.trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--dui-space-1-5);
width: var(--dui-select-trigger-width, fit-content);
height: var(--dui-select-trigger-height, 2.25rem);
padding-block: var(--dui-space-2);
padding-inline: var(--dui-space-2-5) var(--dui-space-2);
border: 1px solid var(--dui-select-trigger-border, var(--dui-color-input));
border-radius: var(--dui-select-trigger-radius, var(--dui-radius-md));
background: var(--dui-select-trigger-bg, transparent);
color: var(--dui-color-foreground);
box-shadow: var(--dui-shadow-xs);
font: inherit;
font-size: var(--dui-text-sm);
line-height: var(--dui-text-sm-line-height);
white-space: nowrap;
cursor: pointer;
outline: none;
transition:
color var(--dui-duration-fast) var(--dui-ease-out),
box-shadow var(--dui-duration-fast) var(--dui-ease-out);
&[data-size='sm'] {
height: var(--dui-select-trigger-height, 2rem);
}
&:focus-visible {
border-color: var(--dui-color-ring);
box-shadow: 0 0 0 var(--dui-ring-width)
color-mix(in oklch, var(--dui-color-ring) 50%, transparent);
}
&[data-placeholder] {
color: var(--dui-color-muted-foreground);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&[aria-invalid='true'] {
border-color: var(--dui-color-destructive);
}
}
.icon {
flex-shrink: 0;
width: 1rem;
height: 1rem;
fill: none;
stroke: var(--dui-color-muted-foreground);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { getSelectContext } from './context.js';
let {
class: className,
placeholder = '',
...restProps
}: HTMLAttributes<HTMLSpanElement> & { placeholder?: string } = $props();
const { select } = getSelectContext();
const label = $derived(select.value ? select.getOptionLabel(select.value) : placeholder);
</script>
<span class={['value', className]} {...restProps}>{label}</span>
<style>
.value {
display: flex;
flex: 1;
align-items: center;
gap: var(--dui-space-1-5);
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
<script lang="ts">
import { Select } from 'melt/builders';
import type { Snippet } from 'svelte';
import { setSelectContext } from './context.js';
let {
value = $bindable(''),
open = $bindable(false),
children
}: { value?: string; open?: boolean; children?: Snippet } = $props();
const select = new Select<string>({
value: () => value || undefined,
onValueChange: (v) => (value = v ?? ''),
open: () => open,
onOpenChange: (v) => (open = v),
sameWidth: false
});
const uid = $props.id();
const ctx = $state({ select, labelId: `${uid}-label`, hasLabel: false });
setSelectContext(ctx);
</script>
{@render children?.()}
Customizing
Set any of these on the component or on a parent element. See Customizing for how they work.
| Custom property | Default |
|---|---|
--dui-select-content-min-width | 9rem |
--dui-select-content-radius | var(--dui-radius-md) |
--dui-select-content-bg | var(--dui-color-popover) |
--dui-select-content-fg | var(--dui-color-popover-foreground) |
--dui-select-item-highlight-bg | var(--dui-color-accent) |
--dui-select-item-highlight-fg | var(--dui-color-accent-foreground) |
--dui-select-trigger-width | fit-content |
--dui-select-trigger-height | 2.25rem |
--dui-select-trigger-border | var(--dui-color-input) |
--dui-select-trigger-radius | var(--dui-radius-md) |
--dui-select-trigger-bg | transparent |