Skip to main content
Nexus Design System
GitHub

NativeSelect

NativeSelect styles the browser <select>, so mobile users get the system picker. Use it for dense forms and long, plain lists. For a styled dropdown use Select, and for search use Combobox.

'use client';

import {
  NativeSelect,
  NativeSelectOption,
} from '@/components/nxds/components/native-select/native-select';

export default function NativeSelectDefault() {
  return (
    <NativeSelect aria-label="Plan" defaultValue="pro">
      <NativeSelectOption value="free">Free</NativeSelectOption>
      <NativeSelectOption value="pro">Pro</NativeSelectOption>
      <NativeSelectOption value="team">Team</NativeSelectOption>
    </NativeSelect>
  );
}

Installation

npx nxds-cli@latest add native-select

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  NativeSelect,
  NativeSelectOptGroup,
  NativeSelectOption,
} from '@/components/nxds/components/native-select/native-select';

Composition

NativeSelect
├── NativeSelectOption
└── NativeSelectOptGroup
    └── NativeSelectOption

Examples

Sizes

Use the sm size for dense forms.

'use client';

import {
  NativeSelect,
  NativeSelectOption,
} from '@/components/nxds/components/native-select/native-select';

export default function NativeSelectSizes() {
  return (
    <div className="nx:flex nx:flex-col nx:gap-3">
      <NativeSelect size="default" aria-label="Default size" defaultValue="pro">
        <NativeSelectOption value="free">Free</NativeSelectOption>
        <NativeSelectOption value="pro">Pro</NativeSelectOption>
      </NativeSelect>
      <NativeSelect size="sm" aria-label="Small size" defaultValue="pro">
        <NativeSelectOption value="free">Free</NativeSelectOption>
        <NativeSelectOption value="pro">Pro</NativeSelectOption>
      </NativeSelect>
    </div>
  );
}

With opt groups

Group options under labels with NativeSelectOptGroup.

'use client';

import {
  NativeSelect,
  NativeSelectOptGroup,
  NativeSelectOption,
} from '@/components/nxds/components/native-select/native-select';

export default function NativeSelectWithOptGroups() {
  return (
    <NativeSelect aria-label="Timezone" defaultValue="pst">
      <NativeSelectOptGroup label="Americas">
        <NativeSelectOption value="pst">Pacific</NativeSelectOption>
        <NativeSelectOption value="est">Eastern</NativeSelectOption>
      </NativeSelectOptGroup>
      <NativeSelectOptGroup label="Europe">
        <NativeSelectOption value="gmt">London</NativeSelectOption>
        <NativeSelectOption value="cet">Berlin</NativeSelectOption>
      </NativeSelectOptGroup>
    </NativeSelect>
  );
}

Invalid

Set aria-invalid to show the error border and focus ring.

'use client';

import {
  NativeSelect,
  NativeSelectOption,
} from '@/components/nxds/components/native-select/native-select';

export default function NativeSelectInvalid() {
  return (
    <NativeSelect aria-label="Plan" aria-invalid defaultValue="free">
      <NativeSelectOption value="free">Free</NativeSelectOption>
      <NativeSelectOption value="pro">Pro</NativeSelectOption>
    </NativeSelect>
  );
}

Disabled

Disable the select to dim it and its chevron and block changes.

'use client';

import {
  NativeSelect,
  NativeSelectOption,
} from '@/components/nxds/components/native-select/native-select';

export default function NativeSelectDisabled() {
  return (
    <NativeSelect aria-label="Plan" defaultValue="free" disabled>
      <NativeSelectOption value="free">Free</NativeSelectOption>
      <NativeSelectOption value="pro">Pro</NativeSelectOption>
    </NativeSelect>
  );
}

Props

NativeSelect

NativeSelect props
PropTypeDefaultDescription
size"default" | "sm" | null"default"No description
variant"borderless" | "bordered" | null"bordered"No description

NativeSelectOptGroup

NativeSelectOptGroup adds no props of its own; it accepts the props of the element or primitive it wraps.

NativeSelectOption

NativeSelectOption adds no props of its own; it accepts the props of the element or primitive it wraps.

NativeSelect styles the browser <select>, so mobile users get the system picker. Use it for dense forms and long, plain lists. For a styled dropdown use Select, and for search use Combobox.

<script setup lang="ts">
import { NativeSelect, NativeSelectOption } from '@/components/native-select';
</script>

<template>
  <NativeSelect aria-label="Plan" default-value="pro">
    <NativeSelectOption value="free">Free</NativeSelectOption>
    <NativeSelectOption value="pro">Pro</NativeSelectOption>
    <NativeSelectOption value="team">Team</NativeSelectOption>
  </NativeSelect>
</template>

Installation

  1. Install the following dependencies:

    npm install @phosphor-icons/vue@^2.2.1 @vueuse/core@^14.1.0 class-variance-authority@^0.7.1
    
  2. Copy and paste the following code into your project.

    components/native-select/NativeSelect.vue
    <script setup lang="ts">
    import { useVModel } from '@vueuse/core';
    import { computed, type HTMLAttributes, useTemplateRef, watch } from 'vue';
    
    import { IconChevronDown } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    
    import { type NativeSelectVariants, nativeSelectVariants } from '.';
    
    export interface NativeSelectProps {
      size?: NativeSelectVariants['size'];
      variant?: NativeSelectVariants['variant'];
      /** The selected value, for `v-model`. */
      modelValue?: string;
      /** The starting value when `v-model` isn't bound. */
      defaultValue?: string;
      class?: HTMLAttributes['class'];
    }
    
    // Attributes belong on the `<select>`, not the wrapper around it.
    defineOptions({ inheritAttrs: false });
    
    const props = withDefaults(defineProps<NativeSelectProps>(), {
      size: 'default',
      variant: 'bordered',
    });
    const emits = defineEmits<{
      'update:modelValue': [value: string];
    }>();
    
    const model = useVModel(props, 'modelValue', emits, {
      passive: true,
      defaultValue: props.defaultValue,
    });
    // Vue writes `''` for an undefined `value`, which would clear the native first-option selection.
    const valueAttrs = computed(() =>
      model.value === undefined ? {} : { value: model.value }
    );
    
    const select = useTemplateRef('select');
    // A model reset to undefined blanks the select, so pick the first enabled option.
    watch(
      model,
      (value) => {
        if (value !== undefined || !select.value) return;
        const first = [...select.value.options].find((option) => !option.disabled);
        if (first) first.selected = true;
      },
      { flush: 'post' }
    );
    </script>
    
    <template>
      <div
        data-slot="native-select-wrapper"
        class="nx:group/native-select nx:relative nx:w-full"
      >
        <select
          ref="select"
          v-bind="{ ...$attrs, ...valueAttrs }"
          data-slot="native-select"
          :data-size="size"
          :data-variant="variant"
          :class="cn(nativeSelectVariants({ size, variant }), props.class)"
          @change="model = ($event.target as HTMLSelectElement).value"
        >
          <slot />
        </select>
        <IconChevronDown
          aria-hidden="true"
          data-slot="native-select-icon"
          class="nx:pointer-events-none nx:absolute nx:top-1/2 nx:right-3.5 nx:size-4 nx:-translate-y-1/2 nx:text-muted-foreground nx:select-none nx:group-has-[select:disabled]/native-select:text-disabled-foreground"
        />
      </div>
    </template>
    
    components/native-select/NativeSelectOptGroup.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface NativeSelectOptGroupProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<NativeSelectOptGroupProps>();
    </script>
    
    <template>
      <optgroup
        data-slot="native-select-optgroup"
        :class="cn('nx:bg-[Canvas] nx:text-[CanvasText]', props.class)"
      >
        <slot />
      </optgroup>
    </template>
    
    components/native-select/NativeSelectOption.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface NativeSelectOptionProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<NativeSelectOptionProps>();
    </script>
    
    <template>
      <option
        data-slot="native-select-option"
        :class="cn('nx:bg-[Canvas] nx:text-[CanvasText]', props.class)"
      >
        <slot />
      </option>
    </template>
    
    components/native-select/index.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    export {
      default as NativeSelect,
      type NativeSelectProps,
    } from './NativeSelect.vue';
    export {
      default as NativeSelectOptGroup,
      type NativeSelectOptGroupProps,
    } from './NativeSelectOptGroup.vue';
    export {
      default as NativeSelectOption,
      type NativeSelectOptionProps,
    } from './NativeSelectOption.vue';
    
    export const nativeSelectVariants = cva(
      [
        'nx:box-border nx:w-full nx:min-w-0 nx:appearance-none nx:rounded-md nx:border-default',
        'nx:text-foreground nx:autofill-text-foreground nx:transition-field',
        'nx:focus-visible:outline-default nx:focus-visible:outline-focus-default nx:focus-visible:border-focus-default',
        'nx:aria-invalid:border-error-border nx:aria-invalid:focus-visible:outline-focus-error nx:aria-invalid:focus-visible:border-focus-error',
        'nx:disabled:cursor-not-allowed nx:disabled:bg-disabled nx:disabled:autofill-bg-disabled nx:disabled:text-disabled-foreground nx:disabled:autofill-text-disabled-foreground',
      ],
      {
        variants: {
          size: {
            default: 'nx:px-3 nx:py-2 nx:pr-9 nx:typography-body-default',
            sm: 'nx:px-2.5 nx:py-1.5 nx:pr-9 nx:typography-body-small',
          },
          variant: {
            bordered:
              'nx:border-border-default nx:bg-container nx:autofill-bg-container nx:enabled:hover:bg-container-hover nx:enabled:hover:autofill-bg-container-hover nx:disabled:border-border-disabled',
            borderless:
              'nx:border-transparent nx:bg-control-background nx:autofill-bg-control-background nx:enabled:hover:bg-control-background-hover nx:enabled:hover:autofill-bg-control-background-hover',
          },
        },
        defaultVariants: {
          size: 'default',
          variant: 'bordered',
        },
      }
    );
    
    export type NativeSelectVariants = VariantProps<typeof nativeSelectVariants>;
    
    lib/icons.ts
    /**
     * Internal Icons
     *
     * This barrel file re-exports icons used internally by the design system.
     * These are hardcoded icons for component states (loading, validation, navigation).
     *
     * User-facing icons should be passed via props by consumers.
     *
     * This set draws Phosphor. `nxds init --icons` installs the same names drawn
     * by another library.
     */
    import { PhCircle } from '@phosphor-icons/vue';
    import { type FunctionalComponent, h } from 'vue';
    
    // Loading states
    export { PhCircleNotch as IconLoader2 } from '@phosphor-icons/vue';
    
    // Navigation
    export {
      PhArrowDown as IconArrowDown,
      PhArrowLeft as IconArrowLeft,
      PhArrowRight as IconArrowRight,
      PhArrowUp as IconArrowUp,
      PhCaretDown as IconChevronDown,
      PhCaretLeft as IconChevronLeft,
      PhCaretRight as IconChevronRight,
      PhCaretUp as IconChevronUp,
      PhDotsThree as IconDots,
      PhSidebarSimple as IconLayoutSidebar,
      PhCaretUpDown as IconSelector,
    } from '@phosphor-icons/vue';
    
    // Actions
    export {
      PhCheck as IconCheck,
      PhCopy as IconCopy,
      PhMinus as IconMinus,
      PhPencilSimple as IconPencil,
      PhPlus as IconPlus,
      PhX as IconX,
    } from '@phosphor-icons/vue';
    
    export const IconCircleFilled: FunctionalComponent = (_props, { attrs }) =>
      h(PhCircle, { weight: 'fill', ...attrs });
    
    // Filter field types
    export {
      PhCalendarBlank as IconCalendar,
      PhHash as IconHash,
      PhTextAa as IconLetterCase,
      PhListBullets as IconList,
    } from '@phosphor-icons/vue';
    
    // Search / input
    export { PhMagnifyingGlass as IconSearch } from '@phosphor-icons/vue';
    
    // Alerts/Status
    export {
      PhWarningCircle as IconAlertCircle,
      PhWarning as IconAlertTriangle,
      PhCheckCircle as IconCircleCheck,
      PhInfo as IconInfoCircle,
    } from '@phosphor-icons/vue';
    
    // Layout
    export {
      PhDotsSixVertical as IconGripVertical,
      PhPalette as IconPalette,
    } from '@phosphor-icons/vue';
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { NativeSelect, NativeSelectOptGroup, NativeSelectOption } from '@/components/native-select';

Examples

Sizes

Use the sm size for dense forms.

<script setup lang="ts">
import { NativeSelect, NativeSelectOption } from '@/components/native-select';
</script>

<template>
  <div class="nx:flex nx:flex-col nx:gap-3">
    <NativeSelect size="default" aria-label="Default size" default-value="pro">
      <NativeSelectOption value="free">Free</NativeSelectOption>
      <NativeSelectOption value="pro">Pro</NativeSelectOption>
    </NativeSelect>
    <NativeSelect size="sm" aria-label="Small size" default-value="pro">
      <NativeSelectOption value="free">Free</NativeSelectOption>
      <NativeSelectOption value="pro">Pro</NativeSelectOption>
    </NativeSelect>
  </div>
</template>

With opt groups

Group options under labels with NativeSelectOptGroup.

<script setup lang="ts">
import {
  NativeSelect,
  NativeSelectOptGroup,
  NativeSelectOption,
} from '@/components/native-select';
</script>

<template>
  <NativeSelect aria-label="Timezone" default-value="pst">
    <NativeSelectOptGroup label="Americas">
      <NativeSelectOption value="pst">Pacific</NativeSelectOption>
      <NativeSelectOption value="est">Eastern</NativeSelectOption>
    </NativeSelectOptGroup>
    <NativeSelectOptGroup label="Europe">
      <NativeSelectOption value="gmt">London</NativeSelectOption>
      <NativeSelectOption value="cet">Berlin</NativeSelectOption>
    </NativeSelectOptGroup>
  </NativeSelect>
</template>

Invalid

Set aria-invalid to show the error border and focus ring.

<script setup lang="ts">
import { NativeSelect, NativeSelectOption } from '@/components/native-select';
</script>

<template>
  <NativeSelect aria-label="Plan" aria-invalid="true" default-value="free">
    <NativeSelectOption value="free">Free</NativeSelectOption>
    <NativeSelectOption value="pro">Pro</NativeSelectOption>
  </NativeSelect>
</template>

Disabled

Disable the select to dim it and its chevron and block changes.

<script setup lang="ts">
import { NativeSelect, NativeSelectOption } from '@/components/native-select';
</script>

<template>
  <NativeSelect aria-label="Plan" default-value="free" disabled>
    <NativeSelectOption value="free">Free</NativeSelectOption>
    <NativeSelectOption value="pro">Pro</NativeSelectOption>
  </NativeSelect>
</template>

Props

NativeSelect

NativeSelect props
PropTypeDefaultDescription
defaultValuestringNo default documented

The starting value when v-model isn't bound.

modelValuestringNo default documented

The selected value, for v-model.

size"default" | "sm" | null"default"No description
variant"bordered" | "borderless" | null"bordered"No description

NativeSelectOptGroup

NativeSelectOptGroup adds no props of its own; it accepts the props of the element or primitive it wraps.

NativeSelectOption

NativeSelectOption adds no props of its own; it accepts the props of the element or primitive it wraps.