← All components

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.

Composes Select and Field.

Install

terminal1 line
npm i mihirmodi-ui motion

The 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

NativeSelect — usage11 lines
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

PropNotes
options(string | { value, label, disabled? })[]Or pass `<option>`s and `<optgroup>`s as children.
placeholderstringA first row that cannot be picked, shown while the value is empty.
size"sm" | "md" | "lg"Matches Input. Select's own trigger is 40 — `lg`.
…select propsSelectHTMLAttributes`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.

packages/ui/src/components/forms/NativeSelect.tsx69 lines
"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>
  );
});
packages/ui/src/components/forms/Input.tsx61 lines
"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}`}
    />
  );
});