Combobox
Enables users to pick from a list of options displayed in a dropdown.
<script lang="ts">
import { Combobox } from "bits-ui";
import CaretUpDown from "phosphor-svelte/lib/CaretUpDown";
import Check from "phosphor-svelte/lib/Check";
import OrangeSlice from "phosphor-svelte/lib/OrangeSlice";
const fruits = [
{ value: "mango", label: "Mango" },
{ value: "watermelon", label: "Watermelon" },
{ value: "apple", label: "Apple" },
{ value: "pineapple", label: "Pineapple" },
{ value: "orange", label: "Orange" },
{ value: "grape", label: "Grape" },
{ value: "strawberry", label: "Strawberry" },
{ value: "banana", label: "Banana" },
{ value: "kiwi", label: "Kiwi" },
{ value: "peach", label: "Peach" },
{ value: "cherry", label: "Cherry" },
{ value: "blueberry", label: "Blueberry" },
{ value: "raspberry", label: "Raspberry" },
{ value: "blackberry", label: "Blackberry" },
{ value: "plum", label: "Plum" },
{ value: "apricot", label: "Apricot" },
{ value: "pear", label: "Pear" },
{ value: "grapefruit", label: "Grapefruit" }
];
let searchValue = $state("");
const filteredFruits = $derived(
searchValue === ""
? fruits
: fruits.filter((fruit) =>
fruit.label.toLowerCase().includes(searchValue.toLowerCase())
)
);
</script>
<Combobox.Root
type="single"
name="favoriteFruit"
onOpenChange={(o) => {
if (!o) searchValue = "";
}}
>
<div class="relative">
<OrangeSlice
class="absolute start-3 top-1/2 size-6 -translate-y-1/2 text-muted-foreground"
/>
<Combobox.Input
oninput={(e) => (searchValue = e.currentTarget.value)}
class="inline-flex h-input w-[296px] truncate rounded-9px border border-border-input bg-background px-11 text-sm transition-colors placeholder:text-foreground-alt/50 focus:outline-none focus:ring-2 focus:ring-foreground focus:ring-offset-2 focus:ring-offset-background"
placeholder="Search a fruit"
aria-label="Search a fruit"
/>
<Combobox.Trigger class="absolute end-3 top-1/2 size-6 -translate-y-1/2">
<CaretUpDown class="size-6 text-muted-foreground" />
</Combobox.Trigger>
</div>
<Combobox.Portal>
<Combobox.Content
class="max-h-96 w-[var(--bits-combobox-trigger-width)] min-w-[var(--bits-combobox-trigger-width)] overflow-y-auto rounded-xl border border-muted bg-background px-1 py-3 shadow-popover outline-none"
sideOffset={10}
>
{#each filteredFruits as fruit, i (i + fruit.value)}
<Combobox.Item
class="flex h-10 w-full select-none items-center rounded-button py-3 pl-5 pr-1.5 text-sm capitalize outline-none duration-75 data-[highlighted]:bg-muted"
value={fruit.value}
label={fruit.label}
>
{#snippet children({ selected })}
{fruit.label}
{#if selected}
<div class="ml-auto">
<Check />
</div>
{/if}
{/snippet}
</Combobox.Item>
{:else}
<span class="block px-5 py-2 text-sm text-muted-foreground">
No results found
</span>
{/each}
</Combobox.Content>
</Combobox.Portal>
</Combobox.Root>
import typography from "@tailwindcss/typography";
import animate from "tailwindcss-animate";
import { fontFamily } from "tailwindcss/defaultTheme";
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./src/**/*.{html,js,svelte,ts}"],
theme: {
container: {
center: true,
screens: {
"2xl": "1440px",
},
},
extend: {
colors: {
border: {
DEFAULT: "hsl(var(--border-card))",
input: "hsl(var(--border-input))",
"input-hover": "hsl(var(--border-input-hover))",
},
background: {
DEFAULT: "hsl(var(--background) / <alpha-value>)",
alt: "hsl(var(--background-alt) / <alpha-value>)",
},
foreground: {
DEFAULT: "hsl(var(--foreground) / <alpha-value>)",
alt: "hsl(var(--foreground-alt) / <alpha-value>)",
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground))",
},
dark: {
DEFAULT: "hsl(var(--dark) / <alpha-value>)",
4: "hsl(var(--dark-04))",
10: "hsl(var(--dark-10))",
40: "hsl(var(--dark-40))",
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
},
contrast: {
DEFAULT: "hsl(var(--contrast) / <alpha-value>)",
},
},
fontFamily: {
sans: ["Inter", ...fontFamily.sans],
mono: ["Source Code Pro", ...fontFamily.mono],
alt: ["Courier", ...fontFamily.sans],
},
fontSize: {
xxs: "10px",
},
borderWidth: {
6: "6px",
},
borderRadius: {
card: "16px",
"card-lg": "20px",
"card-sm": "10px",
input: "9px",
button: "5px",
"5px": "5px",
"9px": "9px",
"10px": "10px",
"15px": "15px",
},
height: {
input: "3rem",
"input-sm": "2.5rem",
},
boxShadow: {
mini: "var(--shadow-mini)",
"mini-inset": "var(--shadow-mini-inset)",
popover: "var(--shadow-popover)",
kbd: "var(--shadow-kbd)",
btn: "var(--shadow-btn)",
card: "var(--shadow-card)",
"date-field-focus": "var(--shadow-date-field-focus)",
},
opacity: {
8: "0.08",
},
scale: {
80: ".80",
98: ".98",
99: ".99",
},
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--bits-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--bits-accordion-content-height)" },
to: { height: "0" },
},
"caret-blink": {
"0%,70%,100%": { opacity: "1" },
"20%,50%": { opacity: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"caret-blink": "caret-blink 1.25s ease-out infinite",
},
},
plugins: [typography, animate],
};
@import url("https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Colors */
--background: 0 0% 100%;
--background-alt: 0 0% 100%;
--foreground: 0 0% 9%;
--foreground-alt: 0 0% 32%;
--muted: 240 5% 96%;
--muted-foreground: 0 0% 9% / 0.4;
--border: 240 6% 10%;
--border-input: 240 6% 10% / 0.17;
--border-input-hover: 240 6% 10% / 0.4;
--border-card: 240 6% 10% / 0.1;
--dark: 240 6% 10%;
--dark-10: 240 6% 10% / 0.1;
--dark-40: 240 6% 10% / 0.4;
--dark-04: 240 6% 10% / 0.04;
--accent: 204 94% 94%;
--accent-foreground: 204 80% 16%;
--destructive: 347 77% 50%;
/* black */
--constrast: 0 0% 0%;
/* Shadows */
--shadow-mini: 0px 1px 0px 1px rgba(0, 0, 0, 0.04);
--shadow-mini-inset: 0px 1px 0px 0px rgba(0, 0, 0, 0.04) inset;
--shadow-popover: 0px 7px 12px 3px hsla(var(--dark-10));
--shadow-kbd: 0px 2px 0px 0px rgba(0, 0, 0, 0.07);
--shadow-btn: 0px 1px 0px 1px rgba(0, 0, 0, 0.03);
--shadow-card: 0px 2px 0px 1px rgba(0, 0, 0, 0.04);
--shadow-date-field-focus: 0px 0px 0px 3px rgba(24, 24, 27, 0.17);
}
.dark {
/* Colors */
--background: 0 0% 5%;
--background-alt: 0 0% 8%;
--foreground: 0 0% 95%;
--foreground-alt: 0 0% 70%;
--muted: 240 4% 16%;
--muted-foreground: 0 0% 100% / 0.4;
--border: 0 0% 96%;
--border-input: 0 0% 96% / 0.17;
--border-input-hover: 0 0% 96% / 0.4;
--border-card: 0 0% 96% / 0.1;
--dark: 0 0% 96%;
--dark-40: 0 0% 96% / 0.4;
--dark-10: 0 0% 96% / 0.1;
--dark-04: 0 0% 96% / 0.04;
--accent: 204 90 90%;
--accent-foreground: 204 94% 94%;
--destructive: 350 89% 60%;
/* white */
--constrast: 0 0% 100%;
/* Shadows */
--shadow-mini: 0px 1px 0px 1px rgba(0, 0, 0, 0.3);
--shadow-mini-inset: 0px 1px 0px 0px rgba(0, 0, 0, 0.5) inset;
--shadow-popover: 0px 7px 12px 3px hsla(0deg 0% 0% / 30%);
--shadow-kbd: 0px 2px 0px 0px rgba(255, 255, 255, 0.07);
--shadow-btn: 0px 1px 0px 1px rgba(0, 0, 0, 0.2);
--shadow-card: 0px 2px 0px 1px rgba(0, 0, 0, 0.4);
--shadow-date-field-focus: 0px 0px 0px 3px rgba(244, 244, 245, 0.1);
}
}
@layer base {
* {
@apply border-border;
}
html {
-webkit-text-size-adjust: 100%;
font-variation-settings: normal;
}
body {
@apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
/* Mobile tap highlight */
/* https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-tap-highlight-color */
html {
-webkit-tap-highlight-color: rgba(128, 128, 128, 0.5);
}
::selection {
background: #fdffa4;
color: black;
}
/* === Scrollbars === */
::-webkit-scrollbar {
@apply w-2;
@apply h-2;
}
::-webkit-scrollbar-track {
@apply !bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply rounded-card-lg !bg-dark-10;
}
::-webkit-scrollbar-corner {
background: rgba(0, 0, 0, 0);
}
/* Firefox */
/* https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-color#browser_compatibility */
html {
scrollbar-color: var(--bg-muted);
}
.antialised {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
@layer utilities {
.step {
counter-increment: step;
}
.step:before {
@apply absolute inline-flex h-9 w-9 items-center justify-center rounded-full border-4 border-background bg-muted text-center -indent-px font-mono text-base font-medium;
@apply ml-[-50px] mt-[-4px];
content: counter(step);
}
}
@layer components {
*:not(body):not(.focus-override) {
outline: none !important;
&:focus-visible {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
}
.link {
@apply inline-flex items-center gap-1 rounded-sm font-medium underline underline-offset-4 hover:text-foreground/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type="number"] {
-moz-appearance: textfield;
}
}
Structure
<script lang="ts">
import { Combobox } from "bits-ui";
</script>
<Combobox.Root>
<Combobox.Input />
<Combobox.Trigger />
<Combobox.Portal>
<Combobox.Content>
<Combobox.Group>
<Combobox.GroupLabel />
<Combobox.Item />
</Combobox.Group>
<Combobox.Item />
</Combobox.Content>
</Combobox.Portal>
</Combobox.Root>
Reusable Components
It's recommended to use the Combobox
primitives to build your own custom combobox component that can be reused throughout your application.
<script lang="ts">
import { Combobox, type WithoutChildrenOrChild, mergeProps } from "bits-ui";
type Item = { value: string; label: string; };
type Props = Combobox.RootProps & {
items: Item[];
inputProps?: WithoutChildrenOrChild<Combobox.InputProps>;
contentProps?: WithoutChildrenOrChild<Combobox.ContentProps>;
}
let {
items,
value = $bindable(),
open = $bindable(false),
inputProps,
contentProps,
...restProps
}: Props = $props();
let searchValue = $state("");
const filteredItems = $derived.by(() => {
if (searchValue === "") return items;
return items.filter((item) => item.label.toLowerCase().includes(searchValue.toLowerCase()));
})
function handleInput(e: Event & { currentTarget: HTMLInputElement }) {
searchValue = e.currentTarget.value;
}
function handleOpenChange(newOpen: boolean) {
if (!newOpen) searchValue = "";
}
const mergedRootProps = $derived(mergeProps(restProps, { onOpenChange: handleOpenChange }))
const mergedInputProps = $derived(mergeProps(inputProps, { oninput: handleInput } ))
</script>
<Combobox.Root bind:value bind:open {...mergedRootProps}>
<Combobox.Input {....mergedInputProps} />
<Combobox.Trigger>Open</Combobox.Trigger>
<Combobox.Portal>
<Combobox.Content {...contentProps}>
{#each filteredItems as item, i (i + item.value)}
<Combobox.Item value={item.value} label={item.label}>
{#snippet children({ selected })}
{item.label}
{selected ? "✅" : ""}
{/snippet}
</Combobox.Item>
{:else}
<span>
No results found
</span>
{/each}
</Combobox.Content>
</Combobox.Portal>
</Combobox.Root>
<script lang="ts">
import { CustomCombobox } from "$lib/components";
const items = [
{ value: "mango", label: "Mango" },
{ value: "watermelon", label: "Watermelon" },
{ value: "apple", label: "Apple" },
// ...
];
</script>
<CustomCombobox {items} />
API Reference
The root combobox component which manages & scopes the state of the combobox.
Property | Type | Description |
---|---|---|
type Required | enum | The type of combobox. Default: undefined |
value bindable prop | union | The value of the combobox. When the type is Default: undefined |
onValueChange | function | A callback that is fired when the combobox value changes. When the type is Default: undefined |
open bindable prop | boolean | The open state of the combobox menu. Default: false |
onOpenChange | function | A callback that is fired when the combobox menu's open state changes. Default: undefined |
disabled | boolean | Whether or not the combobox component is disabled. Default: false |
name | string | The name to apply to the hidden input element for form submission. If provided, a hidden input element will be rendered to submit the value of the combobox. Default: undefined |
required | boolean | Whether or not the combobox menu is required. Default: false |
scrollAlignment | enum | The alignment of the highlighted item when scrolling. Default: 'nearest' |
loop | boolean | Whether or not the combobox menu should loop through items. Default: false |
children | Snippet | The children content to render. Default: undefined |
The element which contains the combobox's items.
Property | Type | Description |
---|---|---|
side | enum | The preferred side of the anchor to render the floating element against when open. Will be reversed when collisions occur. Default: bottom |
sideOffset | number | The distance in pixels from the anchor to the floating element. Default: 0 |
align | enum | The preferred alignment of the anchor to render the floating element against when open. This may change when collisions occur. Default: start |
alignOffset | number | The distance in pixels from the anchor to the floating element. Default: 0 |
arrowPadding | number | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision. Default: 0 |
avoidCollisions | boolean | When Default: true |
collisionBoundary | union | A boundary element or array of elements to check for collisions against. Default: undefined |
collisionPadding | union | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision. Default: 0 |
sticky | enum | The sticky behavior on the align axis. Default: partial |
hideWhenDetached | boolean | When Default: true |
updatePositionStrategy | enum | The strategy to use when updating the position of the content. When Default: optimized |
strategy | enum | The positioning strategy to use for the floating element. When Default: fixed |
preventScroll | boolean | When Default: true |
onEscapeKeydown | function | Callback fired when an escape keydown event occurs in the floating content. You can call Default: undefined |
escapeKeydownBehavior | enum | The behavior to use when an escape keydown event occurs in the floating content. Default: close |
onInteractOutside | function | Callback fired when an outside interaction event completes, which is either a Default: undefined |
onInteractOutsideStart | function | Callback fired when an outside interaction event starts, which is either a Default: undefined |
onFocusOutside | function | Callback fired when focus leaves the dismissable layer. You can call Default: undefined |
interactOutsideBehavior | enum | The behavior to use when an interaction occurs outside of the floating content. Default: close |
onMountAutoFocus | function | Event handler called when auto-focusing the content as it is mounted. Can be prevented. Default: undefined |
onDestroyAutoFocus | function | Event handler called when auto-focusing the content as it is destroyed. Can be prevented. Default: undefined |
trapFocus | boolean | Whether or not to trap the focus within the content when open. Default: true |
preventOverflowTextSelection | boolean | When Default: true |
dir | enum | The reading direction of the app. Default: ltr |
loop | boolean | Whether or not the combobox should loop through items when reaching the end. Default: false |
forceMount | boolean | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content. Default: false |
ref bindable prop | HTMLDivElement | The underlying DOM element being rendered. You can bind to this to get a reference to the element. Default: undefined |
children | Snippet | The children content to render. Default: undefined |
child | Snippet | Use render delegation to render your own element. See delegation docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-state | enum | The combobox's open state. |
data-combobox-content | '' | Present on the content element. |
CSS Variable | Description |
---|---|
--bits-combobox-content-transform-origin | The transform origin of the combobox content element. |
--bits-combobox-content-available-width | The available width of the combobox content element. |
--bits-combobox-content-available-height | The available height of the combobox content element. |
--bits-combobox-trigger-width | The width of the combobox trigger element. |
--bits-combobox-trigger-height | The height of the combobox trigger element. |
A combobox item, which must be a child of the Combobox.Content
component.
Property | Type | Description |
---|---|---|
value Required | string | The value of the item. Default: undefined |
label | string | The label of the item, which is what the list will be filtered by. Default: undefined |
disabled | boolean | Whether or not the combobox item is disabled. This will prevent interaction/selection. Default: false |
onHighlight | function | A callback that is fired when the item is highlighted. Default: undefined |
onUnhighlight | function | A callback that is fired when the item is unhighlighted. Default: undefined |
ref bindable prop | HTMLDivElement | The underlying DOM element being rendered. You can bind to this to get a reference to the element. Default: undefined |
children | Snippet | The children content to render. Default: undefined |
child | Snippet | Use render delegation to render your own element. See delegation docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-value | '' | The value of the combobox item. |
data-label | '' | The label of the combobox item. |
data-disabled | '' | Present when the item is disabled. |
data-highlighted | '' | Present when the item is highlighted, which is either via keyboard navigation of the menu or hover. |
data-selected | '' | Present when the item is selected. |
data-combobox-item | '' | Present on the item element. |
A representation of the combobox input element, which is typically displayed in the content.
Property | Type | Description |
---|---|---|
defaultValue | string | The default value of the input. This is not a reactive prop and is only used to populate the input when the combobox is first mounted if there is already a value set. Default: undefined |
ref bindable prop | HTMLInputElement | The underlying DOM element being rendered. You can bind to this to get a reference to the element. Default: undefined |
children | Snippet | The children content to render. Default: undefined |
child | Snippet | Use render delegation to render your own element. See delegation docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-state | enum | The combobox's open state. |
data-disabled | '' | Present when the combobox is disabled. |
data-combobox-input | '' | Present on the input element. |
A label for the parent combobox group. This is used to describe a group of related combobox items.
Property | Type | Description |
---|---|---|
ref bindable prop | HTMLDivElement | The underlying DOM element being rendered. You can bind to this to get a reference to the element. Default: undefined |
children | Snippet | The children content to render. Default: undefined |
child | Snippet | Use render delegation to render your own element. See delegation docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-combobox-group-label | '' | Present on the group label element. |
An optional arrow element which points to the content when open.
Property | Type | Description |
---|---|---|
width | number | The width of the arrow in pixels. Default: 8 |
height | number | The height of the arrow in pixels. Default: 8 |
ref bindable prop | HTMLDivElement | The underlying DOM element being rendered. You can bind to this to get a reference to the element. Default: undefined |
children | Snippet | The children content to render. Default: undefined |
child | Snippet | Use render delegation to render your own element. See delegation docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-arrow | '' | Present on the arrow element. |