implement
Select

Select

Choose one value, or several, from a list of options.

Apple
Banana
Blueberry
Grapes
Pineapple

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.

Pepperoni
Mushrooms
Onions
Sausage
Olives

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")),
);
Orange
Lemon
Lime
Blueberry
Strawberry
Grapes
Pineapple
Mango

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.

PropTypeDefaultDescription
type"single" | "multiple""single"Whether choosing an item replaces the value, or several can stay selected.
valueSignal<string | null> | Signal<string[]>The selected value. string | null when type is "single", string[] when "multiple". Pass a signal to control it from outside.
openSignal<boolean>falseThe open state. Pass a signal to control it from outside; omit it for uncontrolled state.
preventScrollbooleanfalseWhen true, the page behind cannot scroll while the list is open. The list can still scroll if you give it overflow.
itemsSelectItemData[] | 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 attributeValue
[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.

PropTypeDefaultDescription
placeholderstring""Shown when nothing is selected.
render(props: SelectValueRenderProps) => ChildCalled 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.

PropTypeDefaultDescription
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.
offsetnumber0Distance in pixels between the trigger and the list.
Data attributeValue
[data-select-content]Present
[data-state]"open" | "closed"
[data-side]"top" | "bottom" | "left" | "right"
[data-align]"start" | "center" | "end"
CSS variableDescription
--ip-select-content-transform-originThe transform origin of the content element.
--ip-select-content-available-widthThe available width of the content element.
--ip-select-content-available-heightThe available height of the content element.
--ip-select-anchor-widthThe width of the trigger.
--ip-select-anchor-heightThe height of the trigger.

SelectItem

One option. Sets role="option" and aria-selected. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
value*stringIdentifies the item. Must be unique within the select.
labelstringDisplay and typeahead text. Defaults to the item's text content, or the matching entry in items.
disabledSignal<boolean> | booleanfalsePrevents selecting the item. Sets data-disabled and aria-disabled.
Data attributeValue
[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 attributeValue
[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 attributeValue
[data-select-group-heading]Present