implement
Select

Select

A listbox that drops out of a field, for one value or several.

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}`)),
		),
	);
}

Installation

npx jsrepo add @implementjs/ui/select

jsrepo pulls dropdown-menu along with it, and installs @implementjs/lucide.

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

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(", "))),
		),
	);
}

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

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}`)),
		),
	);
}

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.

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. Styled as a bordered field with a chevron appended. Renders a Button; extra props are forwarded onto it.

Data attributeValue
[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.

PropTypeDefaultDescription
placeholderstring""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.

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.
offsetnumber4Distance 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. Styled with a check on the right that appears while the item is 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. Styled to match the menu group headings. Renders a Div; extra props are forwarded onto it.

Data attributeValue
[data-select-group-heading]Present