Menus & FeedbackSelect

A field you fill by choosing — Radix select on the input hairline.

Preview

The thinking

A select is a field you fill by choosing rather than typing, so I build it as a field, not a button with a menu hanging off it. That one decision is load-bearing: it sets the dress — border-input on bg-transparent, the empty value in text-placeholder — and it sets where the work goes. Radix already owns the hard part — typeahead, focus management, listbox roles — reliably enough that I'd only make it worse by reaching in, so I spend the craft budget on the two seams it can't cover: matching the trigger to the text field beside it, and getting a sound out of a commit that never produces a click. Most of the care here is knowing which problems are already solved.

Interface design

The trigger wears the input's hairline, not a button fill — border-input on a transparent ground, the same dress as the text field it will sit beside in a form — this thing belongs with the fields, not the buttons. The empty state speaks through text-placeholder like any other field. And the caret never rotates: it says "this opens downward", an affordance, not a state readout — the open menu sitting under the field is all the state anyone needs. The menu itself is the house popover surface, bg-popover inside a rounded-lg hairline, and every item indents pl-8 — a gutter held open for the bold check, so picking an option never shifts the text it marks. Highlight is data-[highlighted]:bg-secondary-hover, a wash, not an inversion.

Interaction

Typeahead comes with the primitive: focus the trigger and type, and Radix walks the options — typing at a field you can't type into is still intent, and the primitive treats it as search instead of ignoring it. position="popper" with a sideOffset of 4 keeps the list anchored under the field like a combobox instead of native-select centering over the value, and when the list outgrows max-h-[var(--radix-select-content-available-height)] the caret scroll buttons take over from a bare cut-off edge. The menu enters on animate-pop-in — 200ms, opacity with scale from 0.96 on cubic-bezier(0.23, 0.88, 0.26, 0.92) — growing out of origin-[--radix-select-content-transform-origin], the edge it was summoned from, and leaves on animate-pop-out at 150ms, out faster than in. The trigger only ever changes color, transition-colors duration-150 ease-swift — fields don't move. And the commit rides pointerup, not pointerdown: nothing is chosen until you let go.

Sound design

Half of this control speaks for free: the trigger renders a real button, so the SoundLayer's capturing click listener taps the open the way it taps everything else. The commit is the half that can't — Radix selects on pointerup for the mouse and enter for the keyboard, then unmounts the listbox in the same flush, so no click ever reaches the document listener and role="option" delegation goes deaf at exactly the moment that matters. The root plays the tap itself in onValueChange — once per commit, both input methods — because the contract is "a choice taps", not "a click taps".

Empathy

Disabled keeps the pointer: disabled:opacity-50 with disabled:cursor-not-allowed fades the field and shows the refusing cursor instead of ignoring the hover outright, and quiet comes free — a disabled button never emits the click the SoundLayer listens for. Disabled options go the other way — data-[disabled]:pointer-events-none plus data-[disabled]:opacity-50, dead to the pointer entirely. Keyboard focus draws focus-visible:ring-1 on the ring token and a mouse press never does. Under reduced motion the pop is gone — motion-reduce:animate-none on the menu, motion-reduce:transition-none on the trigger — so everything still opens, it just stops performing. The listbox and option roles, focus management, and typeahead are Radix's, which is most of the reason I built on it. Long values can't stretch the field; [&>span]:line-clamp-1 clamps the chosen value to one line. The one thing this file can't supply is a name — the trigger shows a value, not a label, so every use owes it one, and the demo pays with aria-label="Timezone".
select.tsx
"use client";

import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CaretDown, CaretUp, Check } from "@phosphor-icons/react/dist/ssr";

import { cn } from "@/lib/utils";
import { useSound } from "@/hooks/useSound";
import { SOUNDS } from "@/lib/sounds";

// the select owns its commit tap: radix selects on pointerup (mouse) and
// enter (keyboard), then unmounts the listbox — no click ever reaches
// the SoundLayer's document listener, so role=option delegation can't
// hear it. the root plays the tap in onValueChange instead, which fires
// once per commit for both input methods.
const Select = ({
	onValueChange,
	...props
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Root>) => {
	const [playTap] = useSound(SOUNDS.tap);

	return (
		<SelectPrimitive.Root
			onValueChange={(value) => {
				playTap();
				onValueChange?.(value);
			}}
			{...props}
		/>
	);
};
Select.displayName = SelectPrimitive.Root.displayName;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;

const SelectTrigger = ({
	ref,
	className,
	children,
	...props
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
	ref?: React.Ref<React.ComponentRef<typeof SelectPrimitive.Trigger>>;
}) => (
	<SelectPrimitive.Trigger
		ref={ref}
		// the input hairline, not a button fill — a select is a field you
		// fill by choosing, so it dresses like the fields around it
		className={cn(
			"flex h-9 w-full items-center justify-between gap-2 whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-1 text-sm transition-colors duration-150 ease-swift data-[placeholder]:text-placeholder focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none [&>span]:line-clamp-1",
			className
		)}
		{...props}
	>
		{children}
		<SelectPrimitive.Icon asChild>
			{/* the caret never rotates: it marks "this opens downward", an
			    affordance — not a state readout */}
			<CaretDown className="h-4 w-4 shrink-0 text-muted-foreground" />
		</SelectPrimitive.Icon>
	</SelectPrimitive.Trigger>
);
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;

const SelectScrollUpButton = ({
	ref,
	className,
	...props
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> & {
	ref?: React.Ref<React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>>;
}) => (
	<SelectPrimitive.ScrollUpButton
		ref={ref}
		className={cn(
			"flex cursor-default items-center justify-center py-1 text-muted-foreground",
			className
		)}
		{...props}
	>
		<CaretUp className="h-3.5 w-3.5" />
	</SelectPrimitive.ScrollUpButton>
);
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;

const SelectScrollDownButton = ({
	ref,
	className,
	...props
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> & {
	ref?: React.Ref<React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>>;
}) => (
	<SelectPrimitive.ScrollDownButton
		ref={ref}
		className={cn(
			"flex cursor-default items-center justify-center py-1 text-muted-foreground",
			className
		)}
		{...props}
	>
		<CaretDown className="h-3.5 w-3.5" />
	</SelectPrimitive.ScrollDownButton>
);
SelectScrollDownButton.displayName =
	SelectPrimitive.ScrollDownButton.displayName;

const SelectContent = ({
	ref,
	className,
	children,
	position = "popper",
	sideOffset = 4,
	...props
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
	ref?: React.Ref<React.ComponentRef<typeof SelectPrimitive.Content>>;
}) => (
	<SelectPrimitive.Portal>
		<SelectPrimitive.Content
			ref={ref}
			// popper, not item-aligned: the list stays anchored under the
			// field like a combobox instead of native-select centering
			position={position}
			sideOffset={sideOffset}
			className={cn(
				"relative z-50 max-h-[var(--radix-select-content-available-height)] min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground origin-[--radix-select-content-transform-origin] data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
				className
			)}
			{...props}
		>
			<SelectScrollUpButton />
			<SelectPrimitive.Viewport
				className={cn(
					"p-1",
					position === "popper" &&
						"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
				)}
			>
				{children}
			</SelectPrimitive.Viewport>
			<SelectScrollDownButton />
		</SelectPrimitive.Content>
	</SelectPrimitive.Portal>
);
SelectContent.displayName = SelectPrimitive.Content.displayName;

const SelectLabel = ({
	ref,
	className,
	...props
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> & {
	ref?: React.Ref<React.ComponentRef<typeof SelectPrimitive.Label>>;
}) => (
	<SelectPrimitive.Label
		ref={ref}
		className={cn(
			"py-1.5 pl-8 pr-2 text-sm font-medium text-muted-foreground",
			className
		)}
		{...props}
	/>
);
SelectLabel.displayName = SelectPrimitive.Label.displayName;

const SelectItem = ({
	ref,
	className,
	children,
	...props
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
	ref?: React.Ref<React.ComponentRef<typeof SelectPrimitive.Item>>;
}) => (
	<SelectPrimitive.Item
		ref={ref}
		className={cn(
			"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-secondary-hover data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
			className
		)}
		{...props}
	>
		<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
			<SelectPrimitive.ItemIndicator>
				<Check className="h-3.5 w-3.5" weight="bold" />
			</SelectPrimitive.ItemIndicator>
		</span>
		<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
	</SelectPrimitive.Item>
);
SelectItem.displayName = SelectPrimitive.Item.displayName;

const SelectSeparator = ({
	ref,
	className,
	...props
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> & {
	ref?: React.Ref<React.ComponentRef<typeof SelectPrimitive.Separator>>;
}) => (
	<SelectPrimitive.Separator
		ref={ref}
		className={cn("-mx-1 my-1 h-px bg-border", className)}
		{...props}
	/>
);
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;

export {
	Select,
	SelectGroup,
	SelectValue,
	SelectTrigger,
	SelectContent,
	SelectLabel,
	SelectItem,
	SelectSeparator,
	SelectScrollUpButton,
	SelectScrollDownButton,
};