Skip to content

Select

Wrapper around the native select element.

Browsers
Chrome 135+Firefox 121+
Why this version?

Firefox keeps the native select control. This custom styling uses ::picker(select), :open, and option::checkmark, which Firefox does not support yet.

Safari 17+
Why this version?

Safari keeps the native select control. This custom styling uses ::picker(select), :open, and option::checkmark, which Safari does not support yet.

Tier
0
Bundle compressed
480 B
Forced layout budget
0 ms
Axe budget
0
SSR compatible
Yes
Published contract
Test harness

Playground

Select

Default

Grouped

Pet

Separated

Long value

import { Select } from "@gunkin/uiify";

export default function Example() {
  return (
    <Select.Root className="" name="plan" aria-label="Plan" defaultValue="free">
      <Select.Content>
        <Select.Item value="free">Free</Select.Item>
        <Select.Item value="pro">Pro</Select.Item>
      </Select.Content>
    </Select.Root>
  );
}
@layer components {
  [data-uiify-select] {
    inline-size: 100%;

    /* base-select sizes the closed control to its selected content rather than
       to the widest option, so the control would resize as the value changes.
       A floor keeps it stable. */
    min-inline-size: 12rem;
    max-inline-size: 16rem;
    display: flex;
    align-items: center;
    gap: var(--space-md);
    padding-block: var(--space-sm);
    padding-inline: var(--space-md);
    border: var(--border-width) solid var(--input);
    border-radius: var(--radius-md);
    corner-shape: squircle;
    background-color: var(--card);
    color: var(--foreground);
    font-size: var(--uiify-control-text);
    line-height: var(--leading-normal);
    cursor: pointer;
    transition: var(--transition-colors);
  }

  [data-uiify-select]:focus-visible {
    border-color: var(--primary);
  }

  [data-uiify-select]:disabled,
  [data-uiify-select][data-disabled] {
    opacity: 0.5;
    cursor: not-allowed;
  }

  /* Chevron — a border-corner mark, same technique as the accordion's
     disclosure triangle, replacing the browser's default picker-icon glyph
     (which renders as a crude filled triangle with no theming hook). */
  [data-uiify-select]::picker-icon {
    content: "";
    display: inline-block;
    inline-size: 0.3rem;
    block-size: 0.3rem;

    /* Safari's user-agent sheet pads ::picker-icon, which stretched the corner into a
       chevron about twice Chromium's size. */
    padding: 0;
    margin-block-end: 0.1rem;
    margin-inline-start: auto;
    border-inline-end: 2px solid var(--muted-foreground);
    border-block-end: 2px solid var(--muted-foreground);
    rotate: 45deg;
    transition: rotate var(--duration-fast) var(--ease-out);
  }

  [data-uiify-select]:open::picker-icon {
    margin-block-end: -0.2rem;
    rotate: 225deg;
  }

  /* Picker surface — matches [data-uiify-menu] in menu.css. `inset-block-start` is set
     explicitly with an anchor() offset, not `margin-block-start`: this box
     is already anchor-positioned by the browser's implicit select/picker
     association, and a plain margin on it is accepted (computed style
     reflects it) but has no visible effect. The shared native geometry lives
     in behavior.css; this picker keeps its own explicit gap. */
  [data-uiify-select]::picker(select) {
    min-inline-size: 12rem;
    max-block-size: min(24rem, calc(100dvb - 2rem));
    padding: var(--space-xs);
    border: var(--border-width) solid var(--border);
    border-radius: var(--radius-lg);
    corner-shape: squircle;
    background-color: var(--popover);
    color: var(--popover-foreground);
    box-shadow: var(--shadow-popover);
    overscroll-behavior: contain;
    inset-block-start: calc(anchor(bottom) + var(--space-sm));
  }

  @media (prefers-reduced-motion: no-preference) {
    [data-uiify-select]::picker(select) {
      transition: var(--transition-presence);
    }

    [data-uiify-select]:not(:open)::picker(select) {
      opacity: 0;
      scale: var(--presence-scale);
    }

    @starting-style {
      [data-uiify-select]:open::picker(select) {
        opacity: 0;
        scale: var(--presence-scale);
      }
    }
  }

  /* Group label — matches [data-uiify-menu][data-part="label"], including its padding-inline:
     var(--space-md) inset. <option> is a real DOM child of <optgroup>, so
     that same inline padding would otherwise stack with option's own
     padding-inline and double-indent every grouped item (measured directly:
     +12px versus an ungrouped option) — the option rule just below cancels
     it back out with an equal negative margin plus a matching width bump,
     restoring the same effective inset ungrouped options have. */
  [data-uiify-select] optgroup {
    padding-block-start: var(--space-xs);
    padding-inline: var(--space-md);
    color: var(--muted-foreground);
    font-size: var(--text-xs);
    font-weight: var(--weight-semibold);
  }

  [data-uiify-select] optgroup option {
    margin-inline: calc(-1 * var(--space-md));
    inline-size: calc(100% + 2 * var(--space-md));
  }

  /* Option — matches [data-uiify-menu][data-part="item"]. `color` is set explicitly, not left
     to inherit: a grouped <option> is a DOM child of the muted, semibold
     <optgroup> above, and CSS inheritance would otherwise hand it that same
     muted color (font-weight is unaffected only because the browser's own
     UA stylesheet resets it on <option> directly, which beats inheritance;
     color has no such reset). */
  [data-uiify-select] option {
    display: flex;
    align-items: center;
    column-gap: var(--space-md);
    padding-block: var(--space-sm);
    padding-inline: var(--space-md);
    border-radius: calc(var(--radius-lg) - var(--space-xs));
    corner-shape: squircle;
    color: var(--foreground);
    cursor: pointer;
    font-size: var(--text-sm) !important;
  }

  /* :focus-visible, not :focus: the browser gives the currently-selected
     option real DOM focus the instant the picker opens (a11y default — a
     keyboard/screen-reader user should land on their current value), so
     :focus alone made the selected row look permanently "hovered" even
     before the pointer touched anything, indistinguishable from actually
     hovering a different row. :focus-visible's browser heuristic only
     matches once the user actually drives focus with the keyboard, leaving
     an untouched selection to show nothing but its checkmark. */
  [data-uiify-select] option:hover,
  [data-uiify-select] option:focus-visible {
    background-color: var(--accent);
    color: var(--accent-foreground);
  }

  [data-uiify-select] option:disabled {
    opacity: 0.5;
    pointer-events: none;
  }

  /* Selection marker — matches the menu's checkbox item-indicator (filled
     accent square, not the radio dot): ::checkmark only ever exists on the
     selected option, so there's no separate "unchecked" state to draw. The
     check is punched out of the square with a mask instead of typeset as a
     glyph: U+2713 falls back to a different font in each engine, and in Safari
     it sat above and left of centre. The hole shows the popover surface, which
     is what the theme's --primary-foreground already contrasts with. */
  [data-uiify-select] option::checkmark {
    order: -1;
    box-sizing: border-box;
    inline-size: 1.25rem;
    block-size: 1.25rem;
    flex: 0 0 1.25rem;
    display: block;
    border-radius: var(--radius-xs);
    corner-shape: squircle;
    background: var(--primary);
    content: "";
    mask:
      linear-gradient(#000 0 0),
      url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.6 8.6l3 3L12.4 4.8' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")
        center / 100% 100% no-repeat;
    mask-composite: exclude;
  }

  [data-uiify-select] hr {
    margin-block: var(--space-xs);
    border: 0;
    border-block-start: var(--border-width) solid var(--border);
  }

  @media (prefers-reduced-motion: reduce) {
    [data-uiify-select]::picker-icon,
    [data-uiify-select]::picker(select) {
      transition: none;
    }
  }
}
import { Select } from "@gunkin/uiify";

export default function Example() {
  return (
    <Select.Root
      className={
        "[&[data-positioning=native]]:[--uiify-anchor-offset:var(--space-sm)] [&_[data-positioning=native]]:[--uiify" +
        "-anchor-offset:var(--space-sm)] [&[popover]:popover-open:not([data-positioning=native])]:[margin:revert] [&" +
        "_[popover]:popover-open:not([data-positioning=native])]:[margin:revert] focus-visible:[outline:none] focus-" +
        "visible:[box-shadow:0_0_0_var(--ring-width)_color-mix(in_oklch,_var(--ring)_16%,_transparent)] [inline-size" +
        ":100%] [min-inline-size:12rem] [max-inline-size:16rem] flex items-center gap-md py-sm px-md border-[length:" +
        "var(--border-width)] border-solid border-input rounded-md [corner-shape:squircle] bg-card text-foreground [" +
        "font-size:var(--uiify-control-text)] [line-height:var(--leading-normal)] cursor-pointer [transition:var(--t" +
        "ransition-colors)] focus-visible:border-primary disabled:opacity-50 disabled:cursor-not-allowed data-[disab" +
        "led]:opacity-50 data-[disabled]:cursor-not-allowed [&::picker-icon]:content-[''] [&::picker-icon]:inline-bl" +
        "ock [&::picker-icon]:[inline-size:0.3rem] [&::picker-icon]:[block-size:0.3rem] [&::picker-icon]:p-0 [&::pic" +
        "ker-icon]:[margin-block-end:0.1rem] [&::picker-icon]:[margin-inline-start:auto] [&::picker-icon]:[border-in" +
        "line-end-width:2px] [&::picker-icon]:[border-inline-end-style:solid] [&::picker-icon]:[border-inline-end-co" +
        "lor:var(--muted-foreground)] [&::picker-icon]:[border-block-end-width:2px] [&::picker-icon]:[border-block-e" +
        "nd-style:solid] [&::picker-icon]:[border-block-end-color:var(--muted-foreground)] [&::picker-icon]:[rotate:" +
        "45deg] [&::picker-icon]:[transition:rotate_var(--duration-fast)_var(--ease-out)] [&:open::picker-icon]:[mar" +
        "gin-block-end:-0.2rem] [&:open::picker-icon]:[rotate:225deg] [&::picker(select)]:[min-inline-size:12rem] [&" +
        "::picker(select)]:[max-block-size:min(24rem,_calc(100dvb_-_2rem))] [&::picker(select)]:p-xs [&::picker(sele" +
        "ct)]:border-[length:var(--border-width)] [&::picker(select)]:border-solid [&::picker(select)]:border-border" +
        " [&::picker(select)]:rounded-lg [&::picker(select)]:[corner-shape:squircle] [&::picker(select)]:bg-popover " +
        "[&::picker(select)]:text-popover-foreground [&::picker(select)]:shadow-popover [&::picker(select)]:overscro" +
        "ll-contain [&::picker(select)]:[inset-block-start:calc(anchor(bottom)_+_var(--space-sm))] motion-safe:[&::p" +
        "icker(select)]:[transition:var(--transition-presence)] motion-safe:[&:not(:open)::picker(select)]:opacity-0" +
        " motion-safe:[&:not(:open)::picker(select)]:[scale:var(--presence-scale)] motion-safe:starting:[&:open::pic" +
        "ker(select)]:opacity-0 motion-safe:starting:[&:open::picker(select)]:[scale:var(--presence-scale)] [&_optgr" +
        "oup]:pt-xs [&_optgroup]:px-md [&_optgroup]:text-muted-foreground [&_optgroup]:text-xs [&_optgroup]:font-[nu" +
        "mber:var(--weight-semibold)] [&_optgroup_option]:[margin-inline:calc(-1_*_var(--space-md))] [&_optgroup_opt" +
        "ion]:[inline-size:calc(100%_+_2_*_var(--space-md))] [&_option]:flex [&_option]:items-center [&_option]:gap-" +
        "x-md [&_option]:py-sm [&_option]:px-md [&_option]:[border-radius:calc(var(--radius-lg)_-_var(--space-xs))] " +
        "[&_option]:[corner-shape:squircle] [&_option]:text-foreground [&_option]:cursor-pointer [&_option]:text-sm!" +
        " [&_option:hover]:bg-accent [&_option:hover]:text-accent-foreground [&_option:focus-visible]:bg-accent [&_o" +
        "ption:focus-visible]:text-accent-foreground [&_option:disabled]:opacity-50 [&_option:disabled]:pointer-even" +
        "ts-none [&_option::checkmark]:[order:-1] [&_option::checkmark]:box-border [&_option::checkmark]:[inline-siz" +
        "e:1.25rem] [&_option::checkmark]:[block-size:1.25rem] [&_option::checkmark]:[flex:0_0_1.25rem] [&_option::c" +
        "heckmark]:block [&_option::checkmark]:rounded-xs [&_option::checkmark]:[corner-shape:squircle] [&_option::c" +
        "heckmark]:bg-primary [&_option::checkmark]:content-[''] [&_option::checkmark]:[mask:linear-gradient(#000_0_" +
        "0),_url(\"data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27" +
        "%3E%3Cpath%20d=%27M3.6%208.6l3%203L12.4%204.8%27%20fill=%27none%27%20stroke=%27%23000%27%20stroke-width=%27" +
        "2%27%20stroke-linecap=%27round%27%20stroke-linejoin=%27round%27/%3E%3C/svg%3E\")_center_/_100%_100%_no-repea" +
        "t] [&_option::checkmark]:[mask-composite:exclude] [&_hr]:my-xs [&_hr]:border-0 [&_hr]:border-current [&_hr]" +
        ":border-none [&_hr]:[border-block-start-width:var(--border-width)] [&_hr]:[border-block-start-style:solid] " +
        "[&_hr]:[border-block-start-color:var(--border)] motion-reduce:[&::picker-icon]:[transition:none] motion-red" +
        "uce:[&::picker(select)]:[transition:none]"
      }
      name="plan"
      aria-label="Plan"
      defaultValue="free"
    >
      <Select.Content>
        <Select.Item value="free">Free</Select.Item>
        <Select.Item value="pro">Pro</Select.Item>
      </Select.Content>
    </Select.Root>
  );
}
/* app.css */

@layer reset, ui, app, tokens, base, demo, components, utilities, theme;

@import "@gunkin/uiify/styles/reset";

@import "@gunkin/uiify/components/behavior.css";

@import "@gunkin/uiify/styles/tokens";

@import "@gunkin/uiify/styles/tailwind";

@import "tailwindcss/utilities.css";

@theme { --spacing: 0.25rem; --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; }

@source inline('[&[data-positioning=native]]:[--uiify-anchor-offset:var(--space-sm)] [&_[data-positioning=native]]:[--uiify-anchor-offset:var(--space-sm)] [&[popover]:popover-open:not([data-positioning=native])]:[margin:revert] [&_[popover]:popover-open:not([data-positioning=native])]:[margin:revert] focus-visible:[outline:none] focus-visible:[box-shadow:0_0_0_var(--ring-width)_color-mix(in_oklch,_var(--ring)_16%,_transparent)] [inline-size:100%] [min-inline-size:12rem] [max-inline-size:16rem] flex items-center gap-md py-sm px-md border-[length:var(--border-width)] border-solid border-input rounded-md [corner-shape:squircle] bg-card text-foreground [font-size:var(--uiify-control-text)] [line-height:var(--leading-normal)] cursor-pointer [transition:var(--transition-colors)] focus-visible:border-primary disabled:opacity-50 disabled:cursor-not-allowed data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed [&::picker-icon]:inline-block [&::picker-icon]:[inline-size:0.3rem] [&::picker-icon]:[block-size:0.3rem] [&::picker-icon]:p-0 [&::picker-icon]:[margin-block-end:0.1rem] [&::picker-icon]:[margin-inline-start:auto] [&::picker-icon]:[border-inline-end-width:2px] [&::picker-icon]:[border-inline-end-style:solid] [&::picker-icon]:[border-inline-end-color:var(--muted-foreground)] [&::picker-icon]:[border-block-end-width:2px] [&::picker-icon]:[border-block-end-style:solid] [&::picker-icon]:[border-block-end-color:var(--muted-foreground)] [&::picker-icon]:[rotate:45deg] [&::picker-icon]:[transition:rotate_var(--duration-fast)_var(--ease-out)] [&:open::picker-icon]:[margin-block-end:-0.2rem] [&:open::picker-icon]:[rotate:225deg] [&::picker(select)]:[min-inline-size:12rem] [&::picker(select)]:[max-block-size:min(24rem,_calc(100dvb_-_2rem))] [&::picker(select)]:p-xs [&::picker(select)]:border-[length:var(--border-width)] [&::picker(select)]:border-solid [&::picker(select)]:border-border [&::picker(select)]:rounded-lg [&::picker(select)]:[corner-shape:squircle] [&::picker(select)]:bg-popover [&::picker(select)]:text-popover-foreground [&::picker(select)]:shadow-popover [&::picker(select)]:overscroll-contain [&::picker(select)]:[inset-block-start:calc(anchor(bottom)_+_var(--space-sm))] motion-safe:[&::picker(select)]:[transition:var(--transition-presence)] motion-safe:[&:not(:open)::picker(select)]:opacity-0 motion-safe:[&:not(:open)::picker(select)]:[scale:var(--presence-scale)] motion-safe:starting:[&:open::picker(select)]:opacity-0 motion-safe:starting:[&:open::picker(select)]:[scale:var(--presence-scale)] [&_optgroup]:pt-xs [&_optgroup]:px-md [&_optgroup]:text-muted-foreground [&_optgroup]:text-xs [&_optgroup]:font-[number:var(--weight-semibold)] [&_optgroup_option]:[margin-inline:calc(-1_*_var(--space-md))] [&_optgroup_option]:[inline-size:calc(100%_+_2_*_var(--space-md))] [&_option]:flex [&_option]:items-center [&_option]:gap-x-md [&_option]:py-sm [&_option]:px-md [&_option]:[border-radius:calc(var(--radius-lg)_-_var(--space-xs))] [&_option]:[corner-shape:squircle] [&_option]:text-foreground [&_option]:cursor-pointer [&_option]:text-sm! [&_option:hover]:bg-accent [&_option:hover]:text-accent-foreground [&_option:focus-visible]:bg-accent [&_option:focus-visible]:text-accent-foreground [&_option:disabled]:opacity-50 [&_option:disabled]:pointer-events-none [&_option::checkmark]:[order:-1] [&_option::checkmark]:box-border [&_option::checkmark]:[inline-size:1.25rem] [&_option::checkmark]:[block-size:1.25rem] [&_option::checkmark]:[flex:0_0_1.25rem] [&_option::checkmark]:block [&_option::checkmark]:rounded-xs [&_option::checkmark]:[corner-shape:squircle] [&_option::checkmark]:bg-primary [&_option::checkmark]:[mask:linear-gradient(#000_0_0),_url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%3E%3Cpath%20d=%27M3.6%208.6l3%203L12.4%204.8%27%20fill=%27none%27%20stroke=%27%23000%27%20stroke-width=%272%27%20stroke-linecap=%27round%27%20stroke-linejoin=%27round%27/%3E%3C/svg%3E")_center_/_100%_100%_no-repeat] [&_option::checkmark]:[mask-composite:exclude] [&_hr]:my-xs [&_hr]:border-0 [&_hr]:border-current [&_hr]:border-none [&_hr]:[border-block-start-width:var(--border-width)] [&_hr]:[border-block-start-style:solid] [&_hr]:[border-block-start-color:var(--border)] motion-reduce:[&::picker-icon]:[transition:none] motion-reduce:[&::picker(select)]:[transition:none]');

@source inline("[&::picker-icon]:content-[''] [&_option::checkmark]:content-['']");

@layer base { :root { --uiify-control-text: var(--text-sm); } @media (pointer: coarse) { :root { --uiify-control-text: var(--text-base); } } }

/* Authored animation and registered properties used by the Tailwind skin.
 * Visual rules belong in utility recipes; only at-rule definitions live here. */
@layer components {
  @keyframes uiify-presence-in {
    from {
      opacity: 0;
      scale: var(--presence-scale);
    }
  }
}

@layer components {
  @keyframes uiify-presence-out {
    to {
      opacity: 0;
      scale: var(--presence-scale);
    }
  }
}

@property --uiify-spinner-tail {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

@property --uiify-spinner-length {
  syntax: "<angle>";
  inherits: false;
  initial-value: 36deg;
}

@layer components {
  @keyframes uiify-spinner-rotate {
    to {
      transform: rotate(360deg);
    }
  }
}

@layer components {
  @keyframes uiify-spinner-orbit {
    0% {
      --uiify-spinner-tail: 0deg;
      --uiify-spinner-length: 36deg;
    }

    50% {
      --uiify-spinner-tail: 90deg;
      --uiify-spinner-length: 324deg;
    }

    100% {
      --uiify-spinner-tail: 360deg;
      --uiify-spinner-length: 36deg;
    }
  }
}

@layer components {
  @keyframes uiify-skeleton-pulse {
    50% {
      opacity: 0.45;
    }
  }
}

@layer components {
  @keyframes uiify-skeleton-shimmer {
    to {
      background-position: -200% 0;
    }
  }
}

@layer components {
  @keyframes uiify-skeleton-reveal {
    from {
      opacity: 0;
    }
    to {
      opacity: 1;
    }
  }
}

@property --candle-body-height {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

@property --candle-body-top {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}

@property --candle-slot {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 100%;
}

@property --candle-wick-height {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

@property --candle-wick-top {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}

@property --candle-x {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

@property --candle-scale {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

@property --line-y {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}

@property --line-prev-y {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}

@property --line-count {
  syntax: "<integer>";
  inherits: false;
  initial-value: 1;
}

@property --line-scale {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

@layer components {
  @keyframes uiify-otp-input-caret-blink {
    50% {
      opacity: 0;
    }
  }
}

API

Select.Root

PropTypeDefaultDescription
childrenReactNode—Select parts, normally a Select.Content holding items and groups.
valuestring—Selected value, making the select controlled.
defaultValuestring—Value selected on first render; the native select owns selection from then on, so it works without JavaScript.
onValueChange(value: string) => void—Called with the new value when the selection changes.
namestring—Form field name submitted with the selected value.
disabledbooleanfalseDisables the native select and sets the data-disabled attribute.
renderReactElement | ((props, state) => ReactElement)—Replace the rendered element while keeping behavior and accessibility wiring.

Select.Content

PropTypeDefaultDescription
childrenReactNode—Items, groups and separators; the part renders its children without adding an element.

Select.Item

PropTypeDefaultDescription
childrenReactNode—Text shown for this option.
disabledbooleanfalseMakes this option unselectable.
value*string—Value submitted when this option is selected.
renderReactElement | ((props, state) => ReactElement)—Replace the rendered element while keeping behavior and accessibility wiring.

Select.Group

PropTypeDefaultDescription
childrenReactNode—Items belonging to this group.
label*string—Group heading shown by the native option group.
renderReactElement | ((props, state) => ReactElement)—Replace the rendered element while keeping behavior and accessibility wiring.

Select.Separator

PropTypeDefaultDescription
renderReactElement | ((props, state) => ReactElement)—Replace the rendered element while keeping behavior and accessibility wiring.

Keyboard

KeyAction
Arrow keysChange option when focused
SpaceOpen native picker (platform)

Accessibility

Text-only native <select>; the browser owns selection, keyboard input, typeahead, and form submission. Supporting engines enhance only the picker appearance.

Styling hooks

data-disabled