NativeSelect
The browser's own select, dressed as Select's trigger, for when the phone's picker is better.
On a phone this opens the system’s own wheel.
The idea
On a phone the system picker is the one the reader's thumb already knows, and in a long form a native select works before any script has loaded. Only the closed control is ours — the same hairline, halo and chevron as Select, so the two can sit in one form — and the list that opens is the platform's, kept in the right theme by `color-scheme`. While the placeholder row is showing the text takes the placeholder grey, the same as an empty Input.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Field, Label, NativeSelect } from "mihirmodi-ui";
<Field>
<Label>Week starts on</Label>
<NativeSelect
placeholder="Choose a day"
value={day}
onChange={(e) => setDay(e.target.value)}
options={["Monday", "Saturday", "Sunday"]}
/>
</Field>Props
| Prop | Type | Notes |
|---|---|---|
| options(string | { value, label, disabled? })[] | (string | { value, label, disabled? })[] | Or pass `<option>`s and `<optgroup>`s as children. |
| placeholderstring | string | A first row that cannot be picked, shown while the value is empty. |
| size"sm" | "md" | "lg" | "sm" | "md" | "lg"= "md" | Matches Input. Select's own trigger is 40 — `lg`. |
| …select propsSelectHTMLAttributes | SelectHTMLAttributes | `value`/`onChange` or `defaultValue`, `name`, `required`. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { forwardRef, type SelectHTMLAttributes } from "react";
import { ChevronDownIcon } from "../../lib/glyph.js";
import { useFieldControl } from "./Field.js";
import { controlSizes, controlSurface, type ControlSize } from "./Input.js";
export type NativeSelectOption = { value: string; label: string; disabled?: boolean };
export type NativeSelectProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> & {
size?: ControlSize;
/** Strings, or `{ value, label, disabled? }`. Or pass `<option>`s and `<optgroup>`s as children. */
options?: (string | NativeSelectOption)[];
/** A first, unpickable row shown while the value is empty. */
placeholder?: string;
invalid?: boolean;
};
/**
* The browser's own select, dressed as Select's trigger.
*
* Use it where the platform's picker is the better one — on a phone, where a
* native select opens the wheel or the sheet the reader's thumb already knows,
* or in a long form that must work before any script has loaded. Only the
* closed control is ours: the same hairline, halo and chevron as Select, so
* the two can sit in one form. The list that opens is the system's, in the
* system's colours, which `color-scheme` on the root keeps in the right theme.
*
* The chevron is drawn over the select and ignores the pointer, so the whole
* box still opens the picker. While the placeholder row is showing, the text
* takes the placeholder grey, the same as an empty Input.
*/
export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(function NativeSelect(
{ size = "md", options, placeholder, invalid, className = "", children, value, defaultValue, ...rest },
ref
) {
const field = useFieldControl({ ...rest, invalid });
const items = options?.map((o) => (typeof o === "string" ? { value: o, label: o } : o));
const chevron = size === "sm" ? "right-2 h-3 w-3" : "right-3 h-3.5 w-3.5";
return (
<div className={`relative w-full min-w-0 ${className}`}>
<select
ref={ref}
value={value}
defaultValue={placeholder && value === undefined && defaultValue === undefined ? "" : defaultValue}
{...rest}
{...field}
className={`${controlSurface} ${controlSizes[size]} block cursor-pointer appearance-none truncate ${
size === "sm" ? "pr-7" : "pr-9"
} has-[option[value=""]:checked]:text-faint [&_option]:bg-background-100 [&_option]:text-gray-1000`}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{items?.map((o) => (
<option key={o.value} value={o.value} disabled={o.disabled}>
{o.label}
</option>
))}
{children}
</select>
<span aria-hidden className={`pointer-events-none absolute top-1/2 -translate-y-1/2 text-quiet ${chevron}`}>
<ChevronDownIcon className="h-full w-full" />
</span>
</div>
);
});
"use client";
import { forwardRef, type InputHTMLAttributes } from "react";
import { useFieldControl } from "./Field.js";
export type ControlSize = "sm" | "md" | "lg";
/**
* Height, corner, inset and type for each size — shared by every text control
* here, so an Input, a NativeSelect and a Combobox in one row line up. The
* corner steps down with the height, so a small field is not a pill.
*/
export const controlSizes: Record<ControlSize, string> = {
sm: "h-7 rounded-[8px] px-2.5 text-[13px]",
md: "h-9 rounded-[10px] px-3 text-[14px]",
lg: "h-10 rounded-[10px] px-3.5 text-[14px]",
};
/**
* The material every bordered text control wears: a hairline that steps up a
* grey on hover and focus, a soft three-pixel halo while focused (the same one
* Select wears while open), ink when invalid, and a dim when disabled.
*/
export const controlSurface =
"w-full min-w-0 border-[0.5px] border-gray-alpha-400 bg-background-100 text-gray-1000 transition-[border-color,box-shadow] duration-150 ease-geist placeholder:text-faint hover:border-gray-alpha-600 focus:border-gray-alpha-600 focus:shadow-[0_0_0_3px_var(--color-gray-alpha-200)] aria-invalid:border-gray-1000 disabled:cursor-not-allowed disabled:border-gray-alpha-400 disabled:opacity-50 motion-reduce:transition-none";
export type InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size"> & {
size?: ControlSize;
/** Wrong, without a Field around it. Inside a Field, the field decides. */
invalid?: boolean;
};
/**
* A single line of text.
*
* It is a hairline on the page, not a well: a grey-filled field reads as
* disabled, and a field you can type into should look like the page is
* waiting for you. Focus steps the hairline up and lays a soft halo around it
* — a widening, not a colour — so the field you are in is found at a glance
* without the accent, which belongs to the focus ring the host draws.
*
* Invalid turns the hairline to ink rather than red. The reason, and the mark
* that says there is one, live in the FieldError under it; the border only
* points at which field to read. Inside a Field it takes its id, description,
* error and disabled state from the field, so none of that is written twice.
*/
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ size = "md", invalid, className = "", type = "text", ...rest },
ref
) {
const field = useFieldControl({ ...rest, invalid });
return (
<input
ref={ref}
type={type}
{...rest}
{...field}
className={`${controlSurface} ${controlSizes[size]} file:mr-3 file:h-full file:border-0 file:bg-transparent file:text-[13px] file:font-medium file:text-gray-1000 ${className}`}
/>
);
});