Select
A listbox that drops out of a field, for one value or several.
No fruit selected
import { Div, P, signal } from "@implementjs/core";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/lib/components/ui/select";
const fruits = [
{ value: "apple", label: "Apple" },
{ value: "banana", label: "Banana" },
{ value: "blueberry", label: "Blueberry" },
{ value: "grapes", label: "Grapes", disabled: true },
{ value: "pineapple", label: "Pineapple" },
];
export default function SelectDemo() {
const value = signal<string | null>(null);
return Div(
{ class: "flex w-full max-w-xs flex-col items-center gap-3" },
Select(
{ value, items: fruits },
SelectTrigger(
{ class: "w-48 min-w-48 max-w-48" },
SelectValue({
placeholder: "Select a fruit",
}),
),
SelectContent(
...fruits.map((fruit) =>
SelectItem({ value: fruit.value, disabled: fruit.disabled }, fruit.label),
),
),
),
P(
{ class: "text-sm text-muted-foreground" },
value.bind((selected) => (selected == null ? "No fruit selected" : `Selected: ${selected}`)),
),
);
}Installation
npx jsrepo add @implementjs/ui/select
jsrepo pulls dropdown-menu along with it, and installs @implementjs/lucide.
Copy the file below to src/lib/components/ui/select.ts. It imports cn from utils.ts, which belongs at src/lib/utils.ts, and dropdown-menu from the same directory — copy those in beside it too. Then, on top of @implementjs/core and @implementjs/primitives:
npm install @implementjs/lucide
import { Div, ForEach, If, Span, type Child, type ComponentProps } from "@implementjs/core";
import { CheckIcon, ChevronDownIcon, XIcon } from "@implementjs/lucide";
import {
Select as SelectPrimitive,
SelectContent as SelectContentPrimitive,
SelectGroup as SelectGroupPrimitive,
SelectGroupHeading as SelectGroupHeadingPrimitive,
SelectItem as SelectItemPrimitive,
SelectTrigger as SelectTriggerPrimitive,
SelectValue as SelectValuePrimitive,
} from "@implementjs/primitives";
import { menuGroupHeadingClasses } from "./dropdown-menu";
import { cn } from "@/lib/utils";
import { createComponent } from "@implementjs/primitives";
export type SelectProps = ComponentProps<typeof SelectPrimitive>;
export type SelectTriggerProps = ComponentProps<typeof SelectTriggerPrimitive>;
export type SelectContentProps = ComponentProps<typeof SelectContentPrimitive>;
export type SelectItemProps = ComponentProps<typeof SelectItemPrimitive>;
export type SelectGroupProps = ComponentProps<typeof SelectGroupPrimitive>;
export const SelectGroup = SelectGroupPrimitive;
export type SelectGroupHeadingProps = ComponentProps<typeof SelectGroupHeadingPrimitive>;
export type SelectValueProps = {
placeholder?: string;
};
export const SelectValue = createComponent(function SelectValue({
placeholder = "",
}: SelectValueProps) {
return SelectValuePrimitive({
render: (props) => {
if (props.type === "single") {
return Span(
{ "data-slot": "select-value", class: "truncate" },
If(props.selected.bind((selected) => selected === null))
.Then(Span({ class: "text-muted-foreground" }, placeholder))
.Else(props.selected.bind((selected) => selected?.label ?? "")),
);
}
const values = props.value;
function removeValue(event: Event, id: string) {
event.preventDefault();
event.stopPropagation();
const index = values.get().indexOf(id);
if (index !== -1) values.splice(index, 1);
}
return Div(
{
"data-slot": "select-value",
class:
"flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
},
If(props.selected.bind((selected) => selected.length === 0)).Then(
Span({ class: "text-muted-foreground" }, placeholder),
),
ForEach(
props.selected,
(item) => item.value,
(item) =>
Span(
{
class:
"inline-flex shrink-0 items-center gap-0.5 rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium",
},
Span(
{ class: "truncate" },
item.bind((current) => current.label),
),
Span(
{
role: "button",
tabIndex: -1,
"aria-label": item.bind((current) => `Remove ${current.label}`),
class:
"flex size-3.5 shrink-0 items-center justify-center rounded-sm hover:bg-foreground/10",
onPointerdown: (event) => event.stopPropagation(),
onClick: (event) => removeValue(event, item.get().value),
},
XIcon({ class: "size-3", "aria-hidden": true }),
),
),
),
);
},
});
});
export const Select = createComponent(function Select(props: SelectProps, ...children: Child[]) {
return SelectPrimitive(props, Div({ class: "relative" }, ...children));
});
export const SelectTrigger = createComponent(function SelectTrigger(
{ class: className, type = "button", ...props }: SelectTriggerProps,
...children: Child[]
) {
return SelectTriggerPrimitive(
{
type,
...props,
"data-slot": "select-trigger",
class: cn(
"flex min-h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"disabled:cursor-not-allowed disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
),
},
...children,
ChevronDownIcon({
"aria-hidden": true,
class: "size-4 shrink-0 opacity-50",
}),
);
});
export const SelectContent = createComponent(function SelectContent(
{ offset = 4, side = "bottom", align = "start", class: className, ...props }: SelectContentProps,
...children: Child[]
) {
return SelectContentPrimitive(
{
...props,
offset,
side,
align,
"data-slot": "select-content",
class: cn(
"absolute z-50 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
"w-[var(--ip-select-anchor-width,100%)] min-w-32 origin-(--ip-select-content-transform-origin)",
"max-h-(--ip-select-content-available-height)",
"transition-[opacity,translate,scale,display] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] transition-discrete motion-reduce:transition-none",
"data-[state=open]:block data-[state=open]:translate-0 data-[state=open]:scale-100 data-[state=open]:opacity-100",
"data-[state=closed]:pointer-events-none data-[state=closed]:hidden data-[state=closed]:scale-95 data-[state=closed]:opacity-0",
"data-[state=closed]:data-[side=bottom]:-translate-y-2 data-[state=closed]:data-[side=top]:translate-y-2 data-[state=closed]:data-[side=left]:translate-x-2 data-[state=closed]:data-[side=right]:-translate-x-2",
"starting:data-[state=open]:opacity-0 starting:data-[state=open]:scale-95",
"starting:data-[state=open]:data-[side=bottom]:-translate-y-2 starting:data-[state=open]:data-[side=top]:translate-y-2 starting:data-[state=open]:data-[side=left]:translate-x-2 starting:data-[state=open]:data-[side=right]:-translate-x-2",
className,
),
},
...children,
);
});
export const SelectItem = createComponent(function SelectItem(
{ class: className, ...props }: SelectItemProps,
...children: Child[]
) {
return SelectItemPrimitive(
{
...props,
"data-slot": "select-item",
class: cn(
"group/select-item relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none",
"data-selected:bg-accent/50",
"data-highlighted:bg-accent data-highlighted:text-accent-foreground",
"data-selected:data-highlighted:bg-accent",
"data-disabled:pointer-events-none data-disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
),
},
Span({ class: "flex-1 truncate" }, ...children),
Span(
{
class: "absolute right-2 flex size-3.5 items-center justify-center",
},
CheckIcon({
"aria-hidden": true,
class: "size-4 opacity-0 group-data-selected/select-item:opacity-100",
}),
),
);
});
export const SelectGroupHeading = createComponent(function SelectGroupHeading(
{ class: className, ...props }: SelectGroupHeadingProps,
...children: Child[]
) {
return SelectGroupHeadingPrimitive(
{
...props,
"data-slot": "select-group-heading",
class: cn(menuGroupHeadingClasses, className),
},
...children,
);
});Usage
SelectTrigger is a bordered field with a chevron appended. SelectContent matches the trigger's width, caps itself at the height actually available, and hides itself when closed. Items carry a check on the right while selected.
SelectValue is where the styled layer does real work: it brings its own renderer, so a single select shows a truncated label and a multiple select shows removable chips.
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/lib/components/ui/select";
Select(
{ value: fruit },
SelectTrigger({ class: "w-56" }, SelectValue({ placeholder: "Select a fruit" })),
SelectContent(
SelectItem({ value: "apple" }, "Apple"),
SelectItem({ value: "grapes", disabled: true }, "Grapes"),
),
);
Multiple
Nothing selected
import { Div, P, signal } from "@implementjs/core";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/lib/components/ui/select";
const toppings = [
{ value: "pepperoni", label: "Pepperoni" },
{ value: "mushrooms", label: "Mushrooms" },
{ value: "onions", label: "Onions" },
{ value: "sausage", label: "Sausage" },
{ value: "olives", label: "Olives" },
];
export default function SelectMultipleDemo() {
const value = signal<string[]>([]);
return Div(
{ class: "flex w-full max-w-xs flex-col items-center gap-3" },
Select(
{ type: "multiple", value, items: toppings },
SelectTrigger(
{ class: "h-9 w-56 min-w-56 max-w-56" },
SelectValue({
placeholder: "Select toppings",
}),
),
SelectContent(
...toppings.map((topping) => SelectItem({ value: topping.value }, topping.label)),
),
),
P(
{ class: "text-sm text-muted-foreground" },
value.bind((selected) => (selected.length === 0 ? "Nothing selected" : selected.join(", "))),
),
);
}type: "multiple" turns value into a Signal<string[]>, and SelectValue switches to chips — each with its own remove button that takes the value out without opening the list:
const toppings = signal<string[]>([]);
Select(
{ type: "multiple", value: toppings },
SelectTrigger({ class: "w-72" }, SelectValue({ placeholder: "Pick toppings" })),
SelectContent(SelectItem({ value: "olives" }, "Olives")),
);
Groups
No fruit selected
import { Div, P, signal } from "@implementjs/core";
import {
Select,
SelectContent,
SelectGroup,
SelectGroupHeading,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/lib/components/ui/select";
const citrus = [
{ value: "orange", label: "Orange" },
{ value: "lemon", label: "Lemon" },
{ value: "lime", label: "Lime" },
];
const berries = [
{ value: "blueberry", label: "Blueberry" },
{ value: "strawberry", label: "Strawberry" },
{ value: "grapes", label: "Grapes" },
];
const tropical = [
{ value: "pineapple", label: "Pineapple" },
{ value: "mango", label: "Mango" },
];
const fruits = [...citrus, ...berries, ...tropical];
export default function SelectGroupDemo() {
const value = signal<string | null>(null);
return Div(
{ class: "flex w-full max-w-xs flex-col items-center gap-3" },
Select(
{ value, items: fruits },
SelectTrigger(
{ class: "w-48 min-w-48 max-w-48" },
SelectValue({
placeholder: "Select a fruit",
}),
),
SelectContent(
SelectGroup(
SelectGroupHeading("Citrus"),
...citrus.map((fruit) => SelectItem({ value: fruit.value }, fruit.label)),
),
SelectGroup(
SelectGroupHeading("Berries"),
...berries.map((fruit) => SelectItem({ value: fruit.value }, fruit.label)),
),
SelectGroup(
SelectGroupHeading("Tropical"),
...tropical.map((fruit) => SelectItem({ value: fruit.value }, fruit.label)),
),
),
),
P(
{ class: "text-sm text-muted-foreground" },
value.bind((selected) => (selected == null ? "No fruit selected" : `Selected: ${selected}`)),
),
);
}SelectGroup and SelectGroupHeading divide a long list. The heading is styled to match the menu group headings, which is why the select installs dropdown-menu alongside it.
Sizing
Width belongs on the trigger — the content reads it through --ip-select-anchor-width and matches, so the list never comes out a different size from the field it dropped from.
API Reference
Every prop the styling does not consume is forwarded to the Select primitive, so the tables below are the whole surface — the behavior props and the styling ones together.
Select
The root. Owns whether the list is open, which values are selected, and provides that to the parts inside it. The styled root wraps its children in a positioned Div, which is what the content anchors against.
| Prop | Type | Default | Description |
|---|---|---|---|
type | "single" | "multiple" | "single" | Whether choosing an item replaces the value, or several can stay selected. |
value | Signal<string | null> | Signal<string[]> | — | The selected value. string | null when type is "single", string[] when "multiple". Pass a signal to control it from outside. |
open | Signal<boolean> | false | The open state. Pass a signal to control it from outside; omit it for uncontrolled state. |
preventScroll | boolean | false | When true, the page behind cannot scroll while the list is open. The list can still scroll if you give it overflow. |
items | SelectItemData[] | Readable<SelectItemData[]> | — | Value/label pairs for SelectValue. When omitted, labels come from each item's label prop or its text content. |
SelectTrigger
Toggles the list open and closed. Styled as a bordered field with a chevron appended. Renders a Button; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-state] | "open" | "closed" |
SelectValue
The selected label, for the inside of the trigger. The styled version brings its own render: a truncated label for a single select, and removable chips for a multiple one.
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | string | "" | Shown when nothing is selected. |
SelectContent
The list. Sets role="listbox". Styled as a popover panel that matches the trigger's width, caps itself at the available height, and hides itself when closed. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "bottom" | "left" | "right" | "bottom" | Preferred side of the trigger to place the list. |
align | "start" | "center" | "end" | "start" | How the list aligns along the chosen side. |
offset | number | 4 | Distance in pixels between the trigger and the list. |
| Data attribute | Value |
|---|---|
[data-select-content] | Present |
[data-state] | "open" | "closed" |
[data-side] | "top" | "bottom" | "left" | "right" |
[data-align] | "start" | "center" | "end" |
| CSS variable | Description |
|---|---|
--ip-select-content-transform-origin | The transform origin of the content element. |
--ip-select-content-available-width | The available width of the content element. |
--ip-select-content-available-height | The available height of the content element. |
--ip-select-anchor-width | The width of the trigger. |
--ip-select-anchor-height | The height of the trigger. |
SelectItem
One option. Sets role="option" and aria-selected. Styled with a check on the right that appears while the item is selected. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
value* | string | — | Identifies the item. Must be unique within the select. |
label | string | — | Display and typeahead text. Defaults to the item's text content, or the matching entry in items. |
disabled | Signal<boolean> | boolean | false | Prevents selecting the item. Sets data-disabled and aria-disabled. |
| Data attribute | Value |
|---|---|
[data-select-item] | Present |
[data-selected] | Present when selected |
[data-highlighted] | Present when highlighted |
[data-disabled] | Present when disabled |
SelectGroup
Wraps related items in role="group", labeled by the heading placed inside it. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-select-group] | Present |
SelectGroupHeading
Names the group it sits in; the group points aria-labelledby at it. Styled to match the menu group headings. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-select-group-heading] | Present |