Skip to content

Native Select

ui

Preview

<div class=${nativeSelectWrapperClass()}>
  <select class=${nativeSelectClass()} name="plan">
    <option>Basic</option>
    <option>Pro</option>
    <option>Enterprise</option>
  </select>
  <svg class=${nativeSelectIconClass()} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</div>

Installation

webjs ui add native-select

Sizes

<div class=${nativeSelectWrapperClass()}>
  <select class=${nativeSelectClass()} data-size="default">
    <option>Default</option><option>Other</option>
  </select>
  <svg class=${nativeSelectIconClass()} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</div>


<div class=${nativeSelectWrapperClass()}>
  <select class=${nativeSelectClass()} data-size="sm">
    <option>Small</option><option>Other</option>
  </select>
  <svg class=${nativeSelectIconClass()} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</div>

API Reference

Parts

Name Description
nativeSelectWrapperClass() Relative wrapper that positions the chevron icon.
nativeSelectClass() Apply to a native <select>.
nativeSelectIconClass() Apply to the chevron <svg> rendered absolutely inside the wrapper.
nativeSelectOptionClass() Advanced override, auto-applied via stylesheet, exported for users who need to inline.

Props

Prop Type Default Description
data-size "default" | "sm" "default" Set on the <select> element.

Source: components/ui/native-select.ts

/**
 * NativeSelect: styled native `<select>`. Tier-1 class helpers. Uses
 * `appearance: none` to hide the platform chevron and overlays an SVG
 * chevron on top. Best mobile UX (native picker), full keyboard support,
 * form submission works natively. No JS.
 *
 * shadcn parity:
 *   NativeSelect             → nativeSelectClass()
 *   NativeSelect wrapper     → nativeSelectWrapperClass()
 *   NativeSelect chevron     → nativeSelectIconClass()
 *   NativeSelect option      → bare <option> (or nativeSelectOptionClass()
 *                              for explicit overrides; the installed
 *                              stylesheet sets Canvas / CanvasText on
 *                              every <option> automatically)
 *
 * Importing this module installs a stylesheet that forces Canvas /
 * CanvasText on every <option> inside the wrapper so the dropdown reads
 * in both light and dark themes regardless of OS preference; advanced
 * overrides use `nativeSelectOptionClass()` / `nativeSelectOptGroupClass()`.
 *
 * Design tokens used: --input, --background, --primary, --primary-foreground,
 * --muted-foreground, --ring, --destructive.
 *
 * @example
 * ```html
 * <div class=${nativeSelectWrapperClass()}>
 *   <select class=${nativeSelectClass()} name="plan">
 *     <option>Basic</option>
 *     <option>Pro</option>
 *   </select>
 *   <!-- chevron icon, decorative -->
 *   <svg class="${nativeSelectIconClass()}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
 * </div>
 * ```
 */
import { cn } from '../lib/utils.ts';

export type NativeSelectSize = 'default' | 'sm';

// Auto-apply Canvas/CanvasText to every <option> on the page. Without
// this, an <option> with no explicit bg paints transparent on top of
// the browser-popup background; in dark mode (when color-scheme: dark
// is set on <html>) Chrome's popup is dark, the option's transparent
// bg lets the popup colour through, and the inherited text colour
// from the <select> matches that dark popup: the option disappears,
// only the focused/selected one stays visible because the browser
// overlays its own highlight on it.
//
// Original selector required the option to be inside a
// `.group/native-select` wrapper, on the assumption every user would
// follow the documented Usage block above. But it's easy to write a
// bare <select class=${nativeSelectClass()}> without the wrapper
// (legitimate when you don't need the chevron icon: the popover and
// hover-card docs examples both do this), in which case the rule
// never matched and the dropdown reverted to invisible-options.
// Broadening to `select option, select optgroup` makes the fix work
// everywhere the user has imported native-select, with no required
// wrapper. The Canvas/CanvasText pair is a safe default: they ARE
// the system colours the browser would have painted anyway when no
// rule applied; we just stop relying on inheritance to pull through.
// Selector specificity is 0,0,2 (two elements), so any user who
// genuinely needs custom <option> colours can override with a single
// class anywhere in their cascade (e.g. `.my-select option { ... }`
// at 0,1,2 wins).
//
// `nativeSelectOptionClass()` and `nativeSelectOptGroupClass()` stay
// exported for users who want to opt into the same colours via the
// class helper instead of the global rule. They emit the same
// `bg-[Canvas] text-[CanvasText]` Tailwind utilities: redundant if
// this stylesheet is installed, but harmless and matches the broader
// shadcn convention of "every part has a class helper".
const STYLES = `
select option,
select optgroup {
  background-color: Canvas;
  color: CanvasText;
}
`;

let installed = false;
export function installNativeSelectStyles(): void {
  if (installed || typeof document === 'undefined') return;
  if (document.getElementById('ui-native-select-styles')) {
    installed = true;
    return;
  }
  const style = document.createElement('style');
  style.id = 'ui-native-select-styles';
  style.textContent = STYLES;
  document.head.appendChild(style);
  installed = true;
}

if (typeof document !== 'undefined') installNativeSelectStyles();

export const nativeSelectWrapperClass = (): string =>
  'group/native-select relative w-fit has-[select:disabled]:opacity-50';

export function nativeSelectClass(): string {
  return cn(
    'h-9 w-full min-w-0 appearance-none rounded-md border border-input bg-transparent px-3 py-2 pr-9 text-sm shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed data-[size=sm]:h-8 data-[size=sm]:py-1 dark:bg-input/30 dark:hover:bg-input/50',
    'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
    'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
  );
}

export const nativeSelectIconClass = (): string =>
  'pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 text-muted-foreground opacity-50 select-none';

/** Option / optgroup styling: forces themed background even in dark mode. */
export const nativeSelectOptionClass = (): string => 'bg-[Canvas] text-[CanvasText]';
export const nativeSelectOptGroupClass = (): string => 'bg-[Canvas] text-[CanvasText]';