Select
Choose one value, or several, from a list of options.
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}`)),
),
);
}A select is a button that opens a list of options. Select is the root, SelectTrigger is the control that toggles it, SelectValue is the selected label inside the trigger, SelectContent is the list, and SelectItem is one option. Pass items on the root so SelectValue always has the right labels; without it, labels come from each option's text.
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@implementjs/primitives";
const fruits = [
{ value: "apple", label: "Apple" },
{ value: "banana", label: "Banana" },
];
Select(
{ items: fruits },
SelectTrigger(SelectValue({ placeholder: "Select a fruit" })),
SelectContent(SelectItem({ value: "apple" }, "Apple"), SelectItem({ value: "banana" }, "Banana")),
);
Each part accepts optional props and children — pass a props object when you need attributes, or pass children directly. See createComponent. Extra props on the trigger, content, and items are forwarded onto the underlying Button or Div.
Open state
Select owns whether the list is open. Pass a signal to control it from outside (signal() returns a writable unchanged):
const open = signal(false);
Select(
{ open },
SelectTrigger(SelectValue({ placeholder: "Select" })),
SelectContent(SelectItem({ value: "a" }, "A")),
);
Button({ onClick: () => open.set(false) }, "Close");
The primitive does not hide the content for you. Style it against data-state, the same way Popover does. The page behind stays scrollable while the list is open; pass preventScroll: true to lock it.
Single or multiple
type defaults to "single": choosing an item sets value to that item's string. Pass "multiple" to toggle items in and out of an array instead.
const fruit = signal<string | null>(null);
Select(
{ value: fruit, items: [{ value: "apple", label: "Apple" }] },
SelectTrigger(SelectValue({ placeholder: "Select a fruit" })),
SelectContent(SelectItem({ value: "apple" }, "Apple")),
);
const toppings = signal<string[]>([]);
Select(
{ type: "multiple", value: toppings, items: [{ value: "olives", label: "Olives" }] },
SelectTrigger(SelectValue({ placeholder: "Select toppings" })),
SelectContent(SelectItem({ value: "olives" }, "Olives")),
);
Every item needs a stable value. That string is what the root tracks, so it also has to be unique within the select.
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(", "))),
),
);
}The selected label
SelectValue belongs inside the trigger. The root stores item value strings; SelectValue turns those into labels.
Pass items on the root when you can. That list is the source of truth, so the trigger is correct even before the list mounts, and even if an option's children are more than plain text:
Select(
{
items: [
{ value: "apple", label: "Apple" },
{ value: "banana", label: "Banana" },
],
},
SelectTrigger(SelectValue({ placeholder: "Select a fruit" })),
SelectContent(SelectItem({ value: "apple" }, "Apple"), SelectItem({ value: "banana" }, "Banana")),
);
Without items, the label is the item's label prop, or the text content of the option. A sole string child is available immediately; richer children are read once the option mounts.
placeholder is shown when nothing is selected. Omit render and a single select prints that label, while a multiple select joins labels with a comma.
render is for custom markup. Discriminate on props.type: value is the stored ids (Signal<string | null> or Signal<string[]>), and selected is { value, label } or an array of those:
SelectValue({
placeholder: "Select a fruit",
render: (props) => {
if (props.type === "single") {
return props.selected.bind((item) => item?.label ?? "Select a fruit");
}
return props.selected.bind((items) =>
items.length === 0 ? "Select toppings" : items.map((item) => item.label).join(", "),
);
},
});
Groups
SelectGroup wraps related items in role="group". Put a SelectGroupHeading inside it to name the group; the group points aria-labelledby at that heading.
SelectContent(
SelectGroup(
SelectGroupHeading("Citrus"),
SelectItem({ value: "orange" }, "Orange"),
SelectItem({ value: "lemon" }, "Lemon"),
),
SelectGroup(SelectGroupHeading("Berries"), SelectItem({ value: "blueberry" }, "Blueberry")),
);
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}`)),
),
);
}The trigger and the content
SelectTrigger renders a Button. Clicking it toggles the list.
SelectContent is a Div with role="listbox". Place it next to the trigger. side, align, and offset are the same placement props as Popover:
SelectContent(
{ side: "bottom", align: "start", offset: 4 },
SelectItem({ value: "apple" }, "Apple"),
);
Items
SelectItem is a Div with role="option". Clicking it selects that value (or toggles it when type is "multiple"). Selected items set aria-selected and data-selected. Highlighted items set data-highlighted. Disabled items set data-disabled and aria-disabled, and cannot be selected. Pass label when the visible children are not the typeahead/display text.
SelectItem({ value: "apple" }, "Apple");
SelectItem({ value: "us", label: "United States" }, "US");
Styling
Trigger and content expose data-state as "open" or "closed". Content also sets data-side ("top", "bottom", "left", "right") so motion can slide in from the trigger. Items expose data-selected, data-highlighted, and data-disabled.
Positioning writes CSS variables on the content: --ip-select-content-transform-origin for origin-aware scale, --ip-select-anchor-width / --ip-select-anchor-height to match the trigger, and --ip-select-content-available-width / --ip-select-content-available-height to stay inside the viewport.
SelectTrigger(
{ class: "flex h-9 w-48 items-center justify-between rounded-md border px-3 text-sm" },
SelectValue({ placeholder: "Select a fruit" }),
);
SelectContent(
{
class:
"absolute z-50 min-w-32 origin-(--ip-select-content-transform-origin) rounded-md border bg-popover p-1 shadow-md transition data-[state=closed]:hidden data-[state=closed]:data-[side=bottom]:-translate-y-2",
},
SelectItem(
{
value: "apple",
class:
"rounded-sm px-2 py-1.5 text-sm data-selected:bg-accent/50 data-highlighted:bg-accent data-selected:data-highlighted:bg-accent data-disabled:pointer-events-none data-disabled:opacity-50",
},
"Apple",
),
);
data-state is there for visibility and open versus closed. data-side is the actual placed side (after flip), so enter and exit stay pointed at the trigger.
API Reference
Select
The root. Owns whether the list is open, which values are selected, and provides that to the parts inside it.
| 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. Renders a Button; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-state] | "open" | "closed" |
SelectValue
The selected label. Put it inside the trigger. Uses items on the root when provided, otherwise each option's label or text.
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | string | "" | Shown when nothing is selected. |
render | (props: SelectValueRenderProps) => Child | — | Called with the current selection. Discriminate on props.type: value is the stored ids, selected is { value, label } (or an array of those). Omit it to show the label, or a comma-separated list. |
SelectContent
The list. Sets role="listbox". Style it against data-state and data-side; the primitive does not hide it for you. 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 | 0 | 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. 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. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-select-group-heading] | Present |