Select
select.svelteA dropdown select component with keyboard navigation and popover menu.
Demo
AI Model:
Git Branch:
Disabled:
Usage
001 <script lang="ts">
002 import Select from '$lib/components/select.svelte';
003
004 let model = $state('gemini-3.7-flash');
005 let branch = $state('main');
006
007 const modelOptions = [
008 { value: 'gemini-3.7-flash', label: 'Gemini 3.7 Flash' },
009 { value: 'gemini-3.7-pro', label: 'Gemini 3.7 Pro' },
010 { value: 'claude-3-7-sonnet', label: 'Claude 3.7 Sonnet' },
011 { value: 'legacy-model', label: 'GPT-3.5 (Deprecated)', disabled: true }
012 ];
013
014 const branchOptions = ['main', 'feat-tui-components', 'hotfix-grid', 'staging'];
015 </script>
016
017 <div class="flex flex-wrap items-center gap-6">
018 <div class="flex flex-col gap-1">
019 <span class="text-xs opacity-60">AI Model:</span>
020 <Select items={modelOptions} bind:value={model} />
021 </div>
022
023 <div class="flex flex-col gap-1">
024 <span class="text-xs opacity-60">Git Branch:</span>
025 <Select items={branchOptions} bind:value={branch} />
026 </div>
027
028 <div class="flex flex-col gap-1">
029 <span class="text-xs opacity-60">Disabled:</span>
030 <Select items={['One', 'Two']} disabled />
031 </div>
032 </div>
033
select.svelte
001 <script lang="ts" module>
002 import type { HTMLAttributes } from 'svelte/elements';
003
004 export type SelectItem =
005 | string
006 | {
007 value: string;
008 label?: string;
009 disabled?: boolean;
010 };
011
012 export type SelectProps = Omit<HTMLAttributes<HTMLElement>, 'onchange'> & {
013 ref?: HTMLElement | null;
014 items?: SelectItem[];
015 value?: string;
016 placeholder?: string;
017 disabled?: boolean;
018 name?: string;
019 onchange?: (value: string) => void;
020 };
021 </script>
022
023 <script lang="ts">
024 import { Select } from 'bits-ui';
025
026 let {
027 ref = $bindable(null),
028 items = [],
029 value = $bindable(''),
030 placeholder = 'Select an option...',
031 disabled = false,
032 name,
033 class: className = '',
034 onchange,
035 ...restProps
036 }: SelectProps = $props();
037
038 const normalizedItems = $derived(
039 items.map((item) => (typeof item === 'string' ? { value: item, label: item } : item))
040 );
041
042 const selectedLabel = $derived(
043 normalizedItems.find((i) => i.value === value)?.label ?? value ?? placeholder
044 );
045
046 function handleValueChange(newVal: string | undefined) {
047 if (newVal !== undefined) {
048 value = newVal;
049 onchange?.(newVal);
050 }
051 }
052 </script>
053
054 <div bind:this={ref} class="select-root {className}" {...restProps}>
055 <Select.Root type="single" {value} {disabled} {name} onValueChange={handleValueChange}>
056 <Select.Trigger class="select-trigger">
057 <span class="trigger-label" class:placeholder={!value}>{selectedLabel}</span>
058 <span class="trigger-arrow">▼</span>
059 </Select.Trigger>
060 <Select.Portal>
061 <Select.Content class="select-content" sideOffset={2}>
062 {#each normalizedItems as item (item.value)}
063 {@const isSelected = item.value === value}
064 <Select.Item
065 value={item.value}
066 label={item.label ?? item.value}
067 disabled={item.disabled}
068 class="select-item"
069 >
070 <span class="item-glyph">{isSelected ? '●' : ' '}</span>
071 <span class="item-text">{item.label ?? item.value}</span>
072 </Select.Item>
073 {/each}
074 </Select.Content>
075 </Select.Portal>
076 </Select.Root>
077 </div>
078
079 <style>
080 .select-root {
081 display: inline-flex;
082 font-family: var(--font-family-mono);
083 font-size: var(--font-size);
084 line-height: var(--line);
085 box-sizing: border-box;
086 position: relative;
087 }
088
089 :global(.select-trigger) {
090 display: inline-flex;
091 align-items: center;
092 justify-content: space-between;
093 gap: 1.5ch;
094 height: var(--line);
095 line-height: var(--line);
096 padding: 0 1ch;
097 background: var(--surface-base);
098 color: var(--text-primary);
099 font-family: inherit;
100 font-size: inherit;
101 border: 0;
102 box-shadow: inset 0 0 0 1px var(--border-default);
103 cursor: pointer;
104 outline: 0;
105 user-select: none;
106 min-width: 18ch;
107 box-sizing: border-box;
108 }
109
110 :global(.select-trigger:hover:not(:disabled)) {
111 box-shadow: inset 0 0 0 1px var(--text-primary);
112 }
113
114 :global(.select-trigger:focus-visible) {
115 box-shadow: inset 0 0 0 1px var(--focus-ring);
116 }
117
118 :global(.select-trigger:disabled) {
119 opacity: 0.4;
120 cursor: not-allowed;
121 }
122
123 .trigger-label {
124 flex: 1;
125 white-space: nowrap;
126 overflow: hidden;
127 text-overflow: ellipsis;
128 text-align: left;
129 }
130
131 .trigger-label.placeholder {
132 color: var(--button-muted);
133 }
134
135 .trigger-arrow {
136 color: var(--button-muted);
137 font-size: 0.7em;
138 }
139
140 :global(.select-content) {
141 background: var(--surface-base);
142 color: var(--text-primary);
143 font-family: var(--font-family-mono);
144 font-size: var(--font-size);
145 line-height: var(--line);
146 box-shadow:
147 inset 0 0 0 1px var(--border-default),
148 0.5ch 0.5ch 0 0 var(--border-muted);
149 padding: 0;
150 min-width: 18ch;
151 z-index: 1000;
152 outline: 0;
153 box-sizing: border-box;
154 }
155
156 :global(.select-item) {
157 display: flex;
158 align-items: center;
159 height: var(--line);
160 line-height: var(--line);
161 padding: 0 1ch;
162 gap: 1ch;
163 cursor: pointer;
164 outline: 0;
165 user-select: none;
166 }
167
168 :global(.select-item[data-highlighted]) {
169 background: color-mix(in srgb, var(--focus-ring) 25%, transparent);
170 color: var(--text-primary);
171 }
172
173 :global(.select-item[data-disabled]) {
174 opacity: 0.35;
175 cursor: not-allowed;
176 }
177
178 .item-glyph {
179 color: var(--focus-ring);
180 width: 1ch;
181 flex-shrink: 0;
182 }
183
184 .item-text {
185 flex: 1;
186 white-space: nowrap;
187 }
188 </style>
189