A bare select control (built on Radix's `Select`) — the Radix trigger button is this component's own root element. It has no built-in label/hint/error; wrap it in `FormField` for that chrome.
registry/behivetech/atoms/select at v0.6.2, snapshotted 2026-11-10
select-content.tsx
1import { type ReactNode } from "react";2import * as RadixSelect from "@radix-ui/react-select";3import { getClassName } from "@behivetech/get-class-name";4import styles from "./select-content.module.scss";56export interface SelectContentProps {7 /** Whether the popup is open; adds `--open`, or `--closed` while it animates out */8 open: boolean;9 /** The option items */10 children: ReactNode;11}1213/**14 * Select's popup. A block of its own (`BHT__SelectContent`) so the open and15 * closed animations are styled through modifiers instead of Radix's16 * `data-state`.17 */18export const SelectContent = ({ open, children }: SelectContentProps) => {19 const [rootClass, getChildClass] = getClassName({20 modifiers: { open, closed: !open },21 rootClass: "BHT__SelectContent",22 styles,23 });2425 return (26 <RadixSelect.Content className={rootClass} position="popper" sideOffset={4}>27 <RadixSelect.Viewport className={getChildClass("viewport")}>28 {children}29 </RadixSelect.Viewport>30 </RadixSelect.Content>31 );32};
select-item.tsx
1import { useState } from "react";2import * as RadixSelect from "@radix-ui/react-select";3import { getClassName } from "@behivetech/get-class-name";4import styles from "./select-item.module.scss";56export interface SelectItemProps {7 /** Text shown for this option */8 label: string;9 /** Value submitted/reported when this option is chosen */10 value: string;11 /** Prevents this option from being selected; adds the `--disabled` modifier */12 disabled?: boolean;13 /** Marks this option as the current selection; adds the `--selected` modifier */14 selected?: boolean;15}1617/**18 * One option in Select's popup. A block of its own so `disabled`, `highlighted`19 * and `selected` are styled through modifiers instead of Radix's `data-*` attributes.20 */21export const SelectItem = ({22 label,23 value,24 disabled = false,25 selected = false,26}: SelectItemProps) => {27 // Radix highlights an option exactly while it has focus (pointer move and28 // arrow keys both focus it), so focus and blur are the React signal.29 const [highlighted, setHighlighted] = useState(false);30 const [rootClass, getChildClass] = getClassName({31 modifiers: { disabled, highlighted, selected },32 rootClass: "BHT__SelectItem",33 styles,34 });3536 return (37 <RadixSelect.Item38 value={value}39 disabled={disabled}40 className={rootClass}41 onFocus={() => setHighlighted(true)}42 onBlur={() => setHighlighted(false)}43 >44 <RadixSelect.ItemText>{label}</RadixSelect.ItemText>45 <RadixSelect.ItemIndicator className={getChildClass("indicator")}>46 <svg47 viewBox="0 0 12 10"48 fill="none"49 stroke="currentColor"50 strokeWidth="2.5"51 strokeLinecap="round"52 strokeLinejoin="round"53 aria-hidden="true"54 >55 <path d="M1 5L4.5 8.5L11 1" />56 </svg>57 </RadixSelect.ItemIndicator>58 </RadixSelect.Item>59 );60};
select.tsx
1import * as RadixSelect from "@radix-ui/react-select";2import { useId, useState } from "react";3import { getClassName } from "@behivetech/get-class-name";4import styles from "./select.module.scss";5import { SelectContent } from "./select-content.js";6import { SelectItem } from "./select-item.js";78export interface SelectOption {9 /** Text shown for this option */10 label: string;11 /** Value submitted/reported when this option is chosen */12 value: string;13 /** Prevents this option from being selected */14 disabled?: boolean;15}1617export interface SelectProps extends Omit<18 RadixSelect.SelectTriggerProps,19 "className"20> {21 /** Options rendered in the dropdown */22 options: SelectOption[];23 /** Controlled selected value */24 value?: string;25 /** Initial selected value for uncontrolled usage */26 defaultValue?: string;27 /** Called with the new value when the selection changes */28 onValueChange?: (value: string) => void;29 /** Text shown when no value is selected */30 placeholder?: string;31 /** Marks the field as required for form validation */32 required?: boolean;33 /** Additional class names to merge with the component root element */34 className?: string;35 /** Form field name submitted with the value */36 name?: string;37 /** Marks the field as invalid: adds the `--invalid` modifier and sets `aria-invalid="true"` */38 invalid?: boolean;39}4041/**42 * Bare select control — the Radix select trigger button is this component's43 * own root element, no built-in label/hint/error. Wrap it in `FormField` for44 * that chrome: `<FormField label="..." htmlFor="x"><Select id="x" .../></FormField>`.45 */46export const Select = ({47 options,48 value,49 defaultValue,50 onValueChange,51 placeholder,52 disabled,53 required,54 className,55 id,56 name,57 invalid = false,58 "aria-invalid": ariaInvalid,59 ...rest60}: SelectProps) => {61 const generatedId = useId();62 const selectId = id ?? generatedId;6364 const isInvalid = invalid || ariaInvalid === true || ariaInvalid === "true";6566 // The controlled `value` wins; otherwise the component tracks its own value67 // so `--placeholder` and the selected item work uncontrolled too.68 const [internalValue, setInternalValue] = useState(defaultValue);69 const currentValue = value ?? internalValue;7071 const handleValueChange = (next: string) => {72 if (value === undefined) {73 setInternalValue(next);74 }75 onValueChange?.(next);76 };7778 const [isOpen, setIsOpen] = useState(false);7980 const [rootClass, getChildClass] = getClassName({81 className,82 modifiers: {83 disabled: Boolean(disabled),84 invalid: isInvalid,85 open: isOpen,86 placeholder: currentValue === undefined || currentValue === "",87 },88 rootClass: "BHT__Select",89 styles,90 });9192 return (93 <RadixSelect.Root94 value={currentValue ?? ""}95 onValueChange={handleValueChange}96 open={isOpen}97 onOpenChange={setIsOpen}98 disabled={disabled}99 required={required}100 name={name}101 >102 <RadixSelect.Trigger103 {...rest}104 id={selectId}105 className={rootClass}106 aria-invalid={isInvalid ? true : ariaInvalid}107 >108 <RadixSelect.Value placeholder={placeholder} />109 <RadixSelect.Icon asChild>110 <svg111 className={getChildClass("chevron")}112 viewBox="0 0 12 8"113 fill="none"114 stroke="currentColor"115 strokeWidth="2"116 strokeLinecap="round"117 strokeLinejoin="round"118 aria-hidden="true"119 >120 <path d="M1 1.5L6 6.5L11 1.5" />121 </svg>122 </RadixSelect.Icon>123 </RadixSelect.Trigger>124 <RadixSelect.Portal>125 <SelectContent open={isOpen}>126 {options.map((option) => (127 <SelectItem128 key={option.value}129 label={option.label}130 value={option.value}131 disabled={option.disabled}132 selected={option.value === currentValue}133 />134 ))}135 </SelectContent>136 </RadixSelect.Portal>137 </RadixSelect.Root>138 );139};
select-content.module.scss
1// Material Design 3 menu for Select's popup. Its own block so the open and2// closed animations are `--open` / `--closed` classes.34.BHT__SelectContent {5 // Renders in a portal to document.body, so it is a *sibling* of any Modal6 // or Drawer it was opened from rather than a child — the fallback must stay7 // above --z-modal's (400) or an unstyled consumer gets a popup hidden8 // behind the modal that spawned it.9 //10 // MD3 menus separate from the page by tone and elevation, not a border.11 background: var(12 --md-sys-color-surface-container,13 var(--color-surface-raised)14 );15 border-radius: var(--md-sys-shape-corner-extra-small, 4px);16 box-shadow: var(--md-sys-elevation-2, var(--shadow-md));17 overflow: hidden;18 z-index: var(--z-dropdown, 450);19 min-width: var(--radix-select-trigger-width);20 max-height: var(--radix-select-content-available-height);2122 &--open {23 &.BHT__SelectContent {24 animation: select-in var(--md-sys-motion-duration-short-3, 150ms)25 var(--md-sys-motion-easing-standard-decelerate, ease-out);26 }27 }2829 &--closed {30 &.BHT__SelectContent {31 animation: select-out var(--md-sys-motion-duration-short-2, 100ms)32 var(--md-sys-motion-easing-standard-accelerate, ease-in);33 }34 }3536 &__viewport {37 padding: var(--spacing-1, 0.25rem);38 }39}4041@keyframes select-in {42 from {43 opacity: 0;44 transform: translateY(-0.25rem);45 }4647 to {48 opacity: 1;49 transform: translateY(0);50 }51}5253@keyframes select-out {54 from {55 opacity: 1;56 transform: translateY(0);57 }5859 to {60 opacity: 0;61 transform: translateY(-0.25rem);62 }63}
select-item.module.scss
1// Material Design 3 menu item for Select's popup. Its own block so the2// `--disabled`, `--highlighted` and `--selected` modifiers are real classes.34.BHT__SelectItem {5 position: relative;6 isolation: isolate;7 display: flex;8 align-items: center;9 justify-content: space-between;10 gap: var(--spacing-2, 0.5rem);11 padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);12 border-radius: var(--md-sys-shape-corner-extra-small, 4px);13 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));14 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);15 font-weight: var(--md-sys-typescale-label-large-weight, 500);16 line-height: var(--md-sys-typescale-label-large-line-height, 1.25rem);17 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);18 color: var(--md-sys-color-on-surface, var(--color-on-surface));19 cursor: pointer;20 user-select: none;21 outline: none;2223 // State layer. `--highlighted` follows Radix's highlight, set for both pointer24 // hover and keyboard focus, so it stands in for :hover here.25 &::after {26 content: "";27 position: absolute;28 inset: 0;29 border-radius: inherit;30 background: currentColor;31 opacity: 0;32 pointer-events: none;33 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)34 var(--md-sys-motion-easing-standard, ease);35 }3637 &--highlighted {38 &.BHT__SelectItem::after {39 opacity: var(--md-sys-state-hover-opacity, 0.08);40 }41 }4243 &:active::after {44 opacity: var(--md-sys-state-pressed-opacity, 0.1);45 }4647 // MD3 marks the selected menu item with a tonal container.48 &--selected {49 &.BHT__SelectItem {50 background: var(51 --md-sys-color-secondary-container,52 var(--color-surface-light)53 );54 color: var(55 --md-sys-color-on-secondary-container,56 var(--color-on-surface)57 );58 }59 }6061 &--disabled {62 &.BHT__SelectItem {63 opacity: 0.4;64 cursor: not-allowed;65 pointer-events: none;66 }67 }6869 &__indicator {70 width: 0.75rem;71 height: 0.75rem;72 flex-shrink: 0;73 display: flex;74 align-items: center;75 justify-content: center;7677 svg {78 width: 100%;79 height: 100%;80 }81 }82}
select.module.scss
1// Material Design 3 outlined select: the trigger is an outlined text field and2// the popup is a menu.3//4// The trigger's 2px focus/open edge is a 1px inset shadow on top of the 1px5// border rather than a wider border, so the value never shifts.67@mixin focus-edge {8 outline: none;9 border-color: var(--md-sys-color-primary, var(--color-border-focus));10 box-shadow: inset 0 0 0 1px11 var(--md-sys-color-primary, var(--color-border-focus));12}1314.BHT__Select {15 // Radix Select.Trigger renders as <button>16 position: relative;17 display: flex;18 align-items: center;19 justify-content: space-between;20 gap: var(--spacing-2, 0.5rem);21 width: 100%;22 min-height: 2.5rem;23 padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);24 font-family: var(--md-sys-typescale-body-large-font, var(--font-body));25 font-size: var(--md-sys-typescale-body-large-size, 1rem);26 font-weight: var(--md-sys-typescale-body-large-weight, 400);27 line-height: var(--md-sys-typescale-body-large-line-height, 1.5rem);28 letter-spacing: var(--md-sys-typescale-body-large-tracking, 0.0313em);29 color: var(--md-sys-color-on-surface, var(--color-on-surface));30 // Outlined fields have no container fill; they take the surface they sit on.31 background: transparent;32 border: 1px solid var(--md-sys-color-outline, var(--color-border-strong));33 border-radius: var(--md-sys-shape-corner-extra-small, 4px);34 cursor: pointer;35 text-align: left;36 transition:37 border-color var(--md-sys-motion-duration-short-3, 150ms)38 var(--md-sys-motion-easing-standard, ease),39 box-shadow var(--md-sys-motion-duration-short-3, 150ms)40 var(--md-sys-motion-easing-standard, ease);4142 // Touch target: extends the hit area to 48px without changing the drawn43 // 40px height. px on purpose — a finger doesn't scale with font size.44 &::before {45 content: "";46 position: absolute;47 left: 0;48 right: 0;49 top: 50%;50 translate: 0 -50%;51 height: 100%;52 min-height: 48px;53 }5455 &--placeholder {56 &.BHT__Select {57 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));58 }59 }6061 &:hover:not(.BHT__Select--disabled, .BHT__Select--open, :focus-visible) {62 border-color: var(--md-sys-color-on-surface, var(--color-on-surface));63 }6465 &--open {66 &.BHT__Select {67 @include focus-edge;68 }69 }7071 &:focus-visible {72 @include focus-edge;73 }7475 &--open {76 &.BHT__Select {77 .BHT__Select {78 &__chevron {79 transform: rotate(180deg);80 }81 }82 }83 }8485 &--disabled {86 &.BHT__Select {87 opacity: 0.45;88 cursor: not-allowed;89 }90 }9192 &--invalid {93 &.BHT__Select {94 border-color: var(--md-sys-color-error, var(--color-error));9596 &:hover:not(.BHT__Select--disabled, .BHT__Select--open, :focus-visible) {97 border-color: var(98 --md-sys-color-on-error-container,99 var(--color-error)100 );101 }102103 &.BHT__Select--open,104 &:focus-visible {105 border-color: var(--md-sys-color-error, var(--color-error));106 box-shadow: inset 0 0 0 1px107 var(--md-sys-color-error, var(--color-error));108 }109 }110 }111112 &__chevron {113 width: 0.75rem;114 height: 0.75rem;115 flex-shrink: 0;116 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));117 transition: transform var(--md-sys-motion-duration-short-3, 150ms)118 var(--md-sys-motion-easing-standard, ease);119 }120}
index.ts
1export { Select } from "./select.js";2export type { SelectProps, SelectOption } from "./select.js";
select.example.tsx
1import { useState } from "react";2import { FormField } from "@behivetech/forms.form-field";3import { Select } from "./select.js";45const colorOptions = [6 { label: "Red", value: "red" },7 { label: "Green", value: "green" },8 { label: "Blue", value: "blue" },9];1011const roleOptions = [12 { label: "Admin", value: "admin" },13 { label: "Editor", value: "editor" },14 { label: "Viewer", value: "viewer" },15 { label: "Guest (disabled)", value: "guest", disabled: true },16];1718export const BasicSelect = () => {19 const [value, setValue] = useState("");20 return (21 <Select22 placeholder="Choose a color"23 options={colorOptions}24 value={value}25 onValueChange={setValue}26 />27 );28};2930export const SelectWithFormField = () => {31 const [value, setValue] = useState("");32 return (33 <FormField34 label="Role"35 htmlFor="role"36 hint="Determines what the user can access."37 >38 <Select39 id="role"40 placeholder="Select a role"41 options={roleOptions}42 value={value}43 onValueChange={setValue}44 />45 </FormField>46 );47};4849export const SelectWithError = () => (50 <FormField label="Status" htmlFor="status" error="Please select a status.">51 <Select52 id="status"53 invalid54 placeholder="Select status"55 options={colorOptions}56 />57 </FormField>58);5960export const DisabledSelect = () => (61 <Select disabled options={colorOptions} value="red" />62);