Skip to main content
Nexus Design System
GitHub

Select

Select lets users pick one option from a styled dropdown. Use it when the list needs a styled trigger and menu that match the rest of the UI. For native form submission and OS pickers with plain text options, use NativeSelect.

'use client';

import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/nxds/components/select/select';

export default function SelectDefault() {
  return (
    <Select>
      <SelectTrigger className="nx:w-[180px]" aria-label="Select a fruit">
        <SelectValue placeholder="Select a fruit" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="apple">Apple</SelectItem>
        <SelectItem value="banana">Banana</SelectItem>
        <SelectItem value="orange">Orange</SelectItem>
        <SelectItem value="grape">Grape</SelectItem>
        <SelectItem value="mango">Mango</SelectItem>
      </SelectContent>
    </Select>
  );
}

Installation

npx nxds-cli@latest add select

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

Usage

import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
} from '@/components/nxds/components/select/select';

Composition

Select
├── SelectTrigger
│   └── SelectValue
└── SelectContent
    ├── SelectItem
    ├── SelectGroup
    │   ├── SelectLabel
    │   └── SelectItem
    └── SelectSeparator

Examples

With default value

Open with an option already selected by setting defaultValue.

'use client';

import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/nxds/components/select/select';

export default function SelectWithDefaultValue() {
  return (
    <Select defaultValue="banana">
      <SelectTrigger className="nx:w-[180px]" aria-label="Select a fruit">
        <SelectValue placeholder="Select a fruit" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="apple">Apple</SelectItem>
        <SelectItem value="banana">Banana</SelectItem>
        <SelectItem value="orange">Orange</SelectItem>
      </SelectContent>
    </Select>
  );
}

Invalid trigger

Set aria-invalid on the trigger to show an error border.

'use client';

import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/nxds/components/select/select';

export default function SelectInvalidTrigger() {
  return (
    <Select defaultValue="apple">
      <SelectTrigger
        className="nx:w-[180px]"
        aria-label="Invalid select"
        aria-invalid
      >
        <SelectValue placeholder="Select a fruit" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="apple">Apple</SelectItem>
        <SelectItem value="banana">Banana</SelectItem>
      </SelectContent>
    </Select>
  );
}

With disabled items

Disable single items so users can see but not pick them.

'use client';

import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/nxds/components/select/select';

export default function SelectWithDisabledItems() {
  return (
    <Select>
      <SelectTrigger className="nx:w-[180px]" aria-label="Select an option">
        <SelectValue placeholder="Select option" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="option1">Available</SelectItem>
        <SelectItem value="option2" disabled>
          Unavailable
        </SelectItem>
        <SelectItem value="option3">Available</SelectItem>
      </SelectContent>
    </Select>
  );
}

With groups

Group items under labels and divide the groups with a separator.

'use client';

import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
} from '@/components/nxds/components/select/select';

export default function SelectWithGroups() {
  return (
    <Select>
      <SelectTrigger className="nx:w-[200px]" aria-label="Select a food">
        <SelectValue placeholder="Select a food" />
      </SelectTrigger>
      <SelectContent>
        <SelectGroup>
          <SelectLabel>Fruits</SelectLabel>
          <SelectItem value="apple">Apple</SelectItem>
          <SelectItem value="banana">Banana</SelectItem>
          <SelectItem value="orange">Orange</SelectItem>
        </SelectGroup>
        <SelectSeparator />
        <SelectGroup>
          <SelectLabel>Vegetables</SelectLabel>
          <SelectItem value="carrot">Carrot</SelectItem>
          <SelectItem value="broccoli">Broccoli</SelectItem>
          <SelectItem value="spinach">Spinach</SelectItem>
        </SelectGroup>
      </SelectContent>
    </Select>
  );
}

Sizes

Each trigger size matches the height of an Input at the same size.

'use client';

import { Input } from '@/components/nxds/components/input/input';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/nxds/components/select/select';

const SIZES = ['sm', 'default', 'lg'] as const;

export default function SelectSizes() {
  return (
    <div className="nx:flex nx:w-[420px] nx:flex-col nx:gap-3">
      {SIZES.map((size) => (
        <div key={size} className="nx:flex nx:items-center nx:gap-3">
          <Select>
            <SelectTrigger size={size} aria-label={`${size} select`}>
              <SelectValue placeholder={size} />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="apple">Apple</SelectItem>
            </SelectContent>
          </Select>
          <Input size={size} placeholder={size} aria-label={`${size} input`} />
        </div>
      ))}
    </div>
  );
}

This example also needs: Input.

Props

Select

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

SelectContent

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

SelectGroup

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

SelectItem

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

SelectLabel

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

SelectScrollDownButton

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

SelectScrollUpButton

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

SelectSeparator

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

SelectTrigger

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

SelectValue

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

Select lets users pick one option from a styled dropdown. Use it when the list needs a styled trigger and menu that match the rest of the UI. For native form submission and OS pickers with plain text options, use NativeSelect.

<script setup lang="ts">
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/select';
</script>

<template>
  <Select>
    <SelectTrigger class="nx:w-[180px]" aria-label="Select a fruit">
      <SelectValue placeholder="Select a fruit" />
    </SelectTrigger>
    <SelectContent>
      <SelectItem value="apple">Apple</SelectItem>
      <SelectItem value="banana">Banana</SelectItem>
      <SelectItem value="orange">Orange</SelectItem>
      <SelectItem value="grape">Grape</SelectItem>
      <SelectItem value="mango">Mango</SelectItem>
    </SelectContent>
  </Select>
</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 reka-ui@^2.11.0
    
  2. Copy and paste the following code into your project.

    components/overlay-layout/overlay-layout.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    /**
     * Popover-family surfaces share the translucent/blurred recipe; Tooltip uses a
     * solid carve-out because its small, brief text appears over arbitrary content.
     */
    export const popoverSurfaceClassName = [
      'nx:rounded-md',
      'nx:border-default',
      'nx:border-border-default',
      'nx:bg-popover-alpha',
      'nx:backdrop-blur-md',
      'nx:text-popover-foreground',
      'nx:typography-body-default',
      'nx:shadow-lg',
    ].join(' ');
    
    export const tooltipSurfaceClassName = [
      'nx:rounded-md',
      'nx:border-default',
      'nx:border-border-default',
      'nx:bg-popover',
      'nx:text-popover-foreground',
      'nx:typography-body-default',
      'nx:shadow-lg',
    ].join(' ');
    
    export const navigationMenuInlinePopoverSurfaceClassName = [
      'nx:group-data-[viewport=false]/navigation-menu:rounded-md',
      'nx:group-data-[viewport=false]/navigation-menu:border-default',
      'nx:group-data-[viewport=false]/navigation-menu:border-border-default',
      'nx:group-data-[viewport=false]/navigation-menu:bg-popover-alpha',
      'nx:group-data-[viewport=false]/navigation-menu:backdrop-blur-md',
      'nx:group-data-[viewport=false]/navigation-menu:text-popover-foreground',
      'nx:group-data-[viewport=false]/navigation-menu:shadow-lg',
    ].join(' ');
    
    export const navigationMenuInlinePopoverTransitionClassName = [
      'nx:group-data-[viewport=false]/navigation-menu:transition-[opacity,scale]',
      'nx:group-data-[viewport=false]/navigation-menu:duration-fast',
      'nx:group-data-[viewport=false]/navigation-menu:ease-move',
      'nx:group-data-[viewport=false]/navigation-menu:data-[state=closed]:scale-95',
      'nx:group-data-[viewport=false]/navigation-menu:data-[state=closed]:opacity-0',
      'nx:group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-overlay-presence-exit',
    ].join(' ');
    
    // `animate-overlay-presence-exit` is a non-visual "presence bridge": Reka's
    // Presence only waits on `animationName`, not `transitionend`, so this inert
    // animation keeps a closing overlay mounted while the opacity/scale/translate
    // TRANSITIONS below run the visible exit. It animates an unread custom property (see
    // generateMotionUtilitiesCSS in @nxds/core), never a transitioned property, so it
    // cannot override those transitions.
    const overlayPresenceExitClassName =
      'nx:data-[state=closed]:animate-overlay-presence-exit';
    
    export const overlayFloatingTransitionClassName = [
      'nx:transition-[opacity,scale]',
      'nx:duration-fast',
      'nx:ease-move',
      'nx:data-[state=closed]:scale-95',
      'nx:data-[state=closed]:opacity-0',
      overlayPresenceExitClassName,
    ].join(' ');
    
    const overlayContentTransitionClassName = [
      'nx:transition-[opacity,scale]',
      'nx:duration-default',
      'nx:ease-move',
      'nx:data-[state=closed]:scale-95',
      'nx:data-[state=closed]:opacity-0',
      overlayPresenceExitClassName,
    ].join(' ');
    
    export const overlayScrimTransitionClassName = [
      'nx:transition-opacity',
      'nx:duration-default',
      'nx:ease-move',
      'nx:data-[state=closed]:opacity-0',
      overlayPresenceExitClassName,
    ].join(' ');
    
    export const overlayPanelTransitionClassName = [
      'nx:transition-[translate]',
      'nx:duration-slow',
      'nx:ease-move',
      overlayPresenceExitClassName,
    ].join(' ');
    
    export const overlayContentVariants = cva(
      [
        // Centred with margins, not a translate: a transform would make this the
        // containing block for fixed-position popups a dialog holds.
        'nx:fixed nx:inset-0 nx:z-modal nx:m-auto nx:flex nx:h-fit nx:w-full nx:max-w-lg nx:flex-col',
        'nx:max-h-[calc(100svh-2rem)] nx:overflow-hidden',
        'nx:gap-4 nx:border-default nx:border-border-default nx:bg-container nx:py-6 nx:shadow-lg',
        overlayContentTransitionClassName,
        'nx:sm:rounded-lg',
      ].join(' ')
    );
    
    export const overlayScrimVariants = cva(
      [
        'nx:fixed nx:inset-0 nx:z-modal nx:bg-overlay',
        overlayScrimTransitionClassName,
      ].join(' ')
    );
    
    export const overlayHeaderVariants = cva(
      'nx:flex nx:shrink-0 nx:flex-col nx:gap-1 nx:px-6',
      {
        variants: {
          variant: {
            default: 'nx:text-center nx:sm:text-left',
            center: 'nx:items-center nx:text-center',
          },
        },
        defaultVariants: {
          variant: 'default',
        },
      }
    );
    
    export const overlayBodyClassName = [
      'nx:min-h-0',
      'nx:overflow-y-auto',
      'nx:px-6',
      'nx:focus-visible:outline-2',
      'nx:focus-visible:outline-focus-default',
      'nx:focus-visible:[outline-offset:-2px]',
    ].join(' ');
    
    export const overlayFooterVariants = cva(
      'nx:flex nx:shrink-0 nx:gap-2 nx:px-6',
      {
        variants: {
          orientation: {
            horizontal:
              'nx:flex-col nx:sm:flex-row nx:sm:items-center nx:sm:justify-end',
            vertical: 'nx:flex-col nx:items-stretch nx:*:w-full',
          },
        },
        defaultVariants: {
          orientation: 'horizontal',
        },
      }
    );
    
    export const overlayCloseButtonClassName = [
      'nx:absolute nx:right-6 nx:top-6 nx:rounded-sm nx:border-0 nx:bg-transparent nx:p-1 nx:text-muted-foreground-subtle',
      'nx:transition-control',
      'nx:hover:bg-container-hover nx:hover:text-foreground',
      'nx:focus-visible:bg-container-hover nx:focus-visible:text-foreground',
      'nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default',
      'nx:disabled:pointer-events-none',
    ].join(' ');
    
    export type OverlayHeaderVariants = VariantProps<typeof overlayHeaderVariants>;
    
    export type OverlayVariant = NonNullable<
      VariantProps<typeof overlayHeaderVariants>['variant']
    >;
    
    export type OverlayButtonOrientation = NonNullable<
      VariantProps<typeof overlayFooterVariants>['orientation']
    >;
    
    export type OverlayLayoutContextValue = {
      variant: OverlayVariant;
      buttonOrientation: OverlayButtonOrientation;
    };
    
    export const defaultOverlayLayout: OverlayLayoutContextValue = {
      variant: 'default',
      buttonOrientation: 'horizontal',
    };
    
    export function resolveOverlayButtonOrientation(
      variant: OverlayVariant
    ): OverlayButtonOrientation {
      return variant === 'center' ? 'vertical' : 'horizontal';
    }
    
    components/select/SelectContent.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      SelectContent,
      type SelectContentEmits,
      type SelectContentProps as RekaSelectContentProps,
      SelectPortal,
      SelectViewport,
      useForwardPropsEmits,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import {
      overlayFloatingTransitionClassName,
      popoverSurfaceClassName,
    } from '../overlay-layout/overlay-layout';
    
    import SelectScrollDownButton from './SelectScrollDownButton.vue';
    import SelectScrollUpButton from './SelectScrollUpButton.vue';
    
    export interface SelectContentProps extends RekaSelectContentProps {
      class?: HTMLAttributes['class'];
    }
    
    defineOptions({ inheritAttrs: false });
    
    const props = withDefaults(defineProps<SelectContentProps>(), {
      position: 'popper',
    });
    const emits = defineEmits<SelectContentEmits>();
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardPropsEmits(delegated, emits);
    </script>
    
    <template>
      <SelectPortal>
        <SelectContent
          v-bind="{ ...$attrs, ...forwarded }"
          data-slot="select-content"
          :class="
            cn(
              'nx:relative nx:z-popover nx:max-h-96 nx:min-w-32 nx:overflow-hidden',
              popoverSurfaceClassName,
              overlayFloatingTransitionClassName,
              position === 'popper' &&
                'nx:data-[side=bottom]:translate-y-1 nx:data-[side=left]:-translate-x-1 nx:data-[side=right]:translate-x-1 nx:data-[side=top]:-translate-y-1',
              props.class
            )
          "
        >
          <SelectScrollUpButton />
          <SelectViewport
            :class="
              cn(
                'nx:p-1',
                position === 'popper' &&
                  'nx:h-(--reka-select-trigger-height) nx:w-full nx:min-w-(--reka-select-trigger-width)'
              )
            "
          >
            <slot />
          </SelectViewport>
          <SelectScrollDownButton />
        </SelectContent>
      </SelectPortal>
    </template>
    
    components/select/SelectItem.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      SelectItem,
      type SelectItemEmits,
      type SelectItemProps as RekaSelectItemProps,
      SelectItemText,
      useForwardPropsEmits,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { IconCheck } from '../../lib/icons';
    import { selectionIndicatorMotionClassName } from '../../lib/motion';
    import { cn } from '../../lib/utils';
    
    export interface SelectItemProps extends RekaSelectItemProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<SelectItemProps>();
    const emits = defineEmits<SelectItemEmits>();
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardPropsEmits(delegated, emits);
    </script>
    
    <template>
      <SelectItem
        v-bind="forwarded"
        data-slot="select-item"
        :class="
          cn(
            'nx:group nx:relative nx:flex nx:w-full nx:cursor-default nx:select-none nx:items-center',
            'nx:rounded-sm nx:py-1.5 nx:pl-8 nx:pr-2 nx:typography-body-default nx:outline-none',
            'nx:focus:bg-popover-hover nx:focus:text-popover-foreground',
            'nx:data-disabled:pointer-events-none nx:data-disabled:text-disabled-foreground',
            props.class
          )
        "
      >
        <span
          class="nx:pointer-events-none nx:absolute nx:left-2 nx:flex nx:size-3.5 nx:items-center nx:justify-center"
        >
          <IconCheck
            data-slot="select-item-indicator-icon"
            aria-hidden="true"
            :class="
              cn(
                'nx:size-4',
                selectionIndicatorMotionClassName,
                'nx:group-data-[state=checked]:scale-100 nx:group-data-[state=checked]:opacity-100'
              )
            "
          />
        </span>
        <SelectItemText><slot /></SelectItemText>
      </SelectItem>
    </template>
    
    components/select/SelectLabel.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      SelectLabel,
      type SelectLabelProps as RekaSelectLabelProps,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface SelectLabelProps extends RekaSelectLabelProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<SelectLabelProps>();
    
    const delegated = reactiveOmit(props, 'class');
    </script>
    
    <template>
      <SelectLabel
        v-bind="delegated"
        data-slot="select-label"
        :class="cn('nx:px-2 nx:py-1.5 nx:typography-label-default', props.class)"
      >
        <slot />
      </SelectLabel>
    </template>
    
    components/select/SelectScrollDownButton.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      SelectScrollDownButton,
      type SelectScrollDownButtonProps as RekaSelectScrollDownButtonProps,
      useForwardProps,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { IconChevronDown } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    
    export interface SelectScrollDownButtonProps extends RekaSelectScrollDownButtonProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<SelectScrollDownButtonProps>();
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardProps(delegated);
    </script>
    
    <template>
      <SelectScrollDownButton
        v-bind="forwarded"
        data-slot="select-scroll-down-button"
        :class="
          cn(
            'nx:flex nx:cursor-default nx:items-center nx:justify-center nx:py-1',
            props.class
          )
        "
      >
        <IconChevronDown class="nx:size-4" />
      </SelectScrollDownButton>
    </template>
    
    components/select/SelectScrollUpButton.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      SelectScrollUpButton,
      type SelectScrollUpButtonProps as RekaSelectScrollUpButtonProps,
      useForwardProps,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { IconChevronUp } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    
    export interface SelectScrollUpButtonProps extends RekaSelectScrollUpButtonProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<SelectScrollUpButtonProps>();
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardProps(delegated);
    </script>
    
    <template>
      <SelectScrollUpButton
        v-bind="forwarded"
        data-slot="select-scroll-up-button"
        :class="
          cn(
            'nx:flex nx:cursor-default nx:items-center nx:justify-center nx:py-1',
            props.class
          )
        "
      >
        <IconChevronUp class="nx:size-4" />
      </SelectScrollUpButton>
    </template>
    
    components/select/SelectSeparator.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      SelectSeparator,
      type SelectSeparatorProps as RekaSelectSeparatorProps,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface SelectSeparatorProps extends RekaSelectSeparatorProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<SelectSeparatorProps>();
    
    const delegated = reactiveOmit(props, 'class');
    </script>
    
    <template>
      <SelectSeparator
        v-bind="delegated"
        data-slot="select-separator"
        :class="
          cn('nx:-mx-1 nx:my-1 nx:h-px nx:bg-border-default-alpha', props.class)
        "
      />
    </template>
    
    components/select/SelectTrigger.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      SelectIcon,
      SelectTrigger,
      type SelectTriggerProps as RekaSelectTriggerProps,
      useForwardProps,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { IconChevronDown } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    
    import { type SelectTriggerVariants, selectTriggerVariants } from '.';
    
    export interface SelectTriggerProps extends RekaSelectTriggerProps {
      size?: SelectTriggerVariants['size'];
      variant?: SelectTriggerVariants['variant'];
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<SelectTriggerProps>();
    
    const delegated = reactiveOmit(props, 'class', 'size', 'variant');
    const forwarded = useForwardProps(delegated);
    </script>
    
    <template>
      <SelectTrigger
        v-bind="forwarded"
        data-slot="select-trigger"
        :data-size="size ?? 'default'"
        :data-variant="variant ?? 'bordered'"
        :class="cn(selectTriggerVariants({ size, variant }), props.class)"
      >
        <slot />
        <SelectIcon as-child>
          <IconChevronDown
            class="nx:size-4 nx:text-muted-foreground nx:group-disabled/select-trigger:text-disabled-foreground"
          />
        </SelectIcon>
      </SelectTrigger>
    </template>
    
    components/select/index.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    import { fieldSizeVariants } from '../../lib/field-size';
    
    export {
      default as SelectContent,
      type SelectContentProps,
    } from './SelectContent.vue';
    export { default as SelectItem, type SelectItemProps } from './SelectItem.vue';
    export {
      default as SelectLabel,
      type SelectLabelProps,
    } from './SelectLabel.vue';
    export {
      default as SelectScrollDownButton,
      type SelectScrollDownButtonProps,
    } from './SelectScrollDownButton.vue';
    export {
      default as SelectScrollUpButton,
      type SelectScrollUpButtonProps,
    } from './SelectScrollUpButton.vue';
    export {
      default as SelectSeparator,
      type SelectSeparatorProps,
    } from './SelectSeparator.vue';
    export {
      default as SelectTrigger,
      type SelectTriggerProps,
    } from './SelectTrigger.vue';
    export {
      SelectRoot as Select,
      SelectGroup,
      type SelectGroupProps,
      type SelectRootProps as SelectProps,
      SelectValue,
      type SelectValueProps,
    } from 'reka-ui';
    
    export const selectTriggerVariants = cva(
      [
        'nx:group/select-trigger nx:flex nx:box-border nx:w-full nx:items-center nx:justify-between nx:gap-2',
        'nx:rounded-md nx:border-default nx:transition-field',
        'nx:whitespace-nowrap nx:text-inherit',
        'nx:data-[placeholder]:text-muted-foreground',
        '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:text-disabled-foreground',
        'nx:[&>span]:line-clamp-1',
      ],
      {
        variants: {
          size: fieldSizeVariants,
          variant: {
            bordered:
              'nx:border-border-default nx:bg-container nx:enabled:hover:bg-container-hover nx:disabled:border-border-disabled',
            borderless:
              'nx:border-transparent nx:bg-control-background nx:enabled:hover:bg-control-background-hover',
          },
        },
        defaultVariants: {
          size: 'default',
          variant: 'bordered',
        },
      }
    );
    
    export type SelectTriggerVariants = VariantProps<typeof selectTriggerVariants>;
    
    lib/field-size.ts
    export const fieldSizeVariants = {
      default: 'nx:h-10 nx:px-3 nx:py-0 nx:typography-body-default',
      sm: 'nx:h-8 nx:px-2.5 nx:py-0 nx:typography-body-small',
      lg: 'nx:h-12 nx:px-3.5 nx:py-0 nx:typography-body-default',
    };
    
    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';
    
    lib/motion.ts
    export const selectionIndicatorMotionClassName =
      'nx:scale-50 nx:opacity-0 nx:transition-[opacity,scale] nx:duration-fast nx:ease-enter';
    
  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 { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/select';

Examples

With default value

Open with an option already selected by setting defaultValue.

<script setup lang="ts">
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/select';
</script>

<template>
  <Select default-value="banana">
    <SelectTrigger class="nx:w-[180px]" aria-label="Select a fruit">
      <SelectValue placeholder="Select a fruit" />
    </SelectTrigger>
    <SelectContent>
      <SelectItem value="apple">Apple</SelectItem>
      <SelectItem value="banana">Banana</SelectItem>
      <SelectItem value="orange">Orange</SelectItem>
    </SelectContent>
  </Select>
</template>

Invalid trigger

Set aria-invalid on the trigger to show an error border.

<script setup lang="ts">
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/select';
</script>

<template>
  <Select default-value="apple">
    <SelectTrigger
      class="nx:w-[180px]"
      aria-label="Invalid select"
      aria-invalid="true"
    >
      <SelectValue placeholder="Select a fruit" />
    </SelectTrigger>
    <SelectContent>
      <SelectItem value="apple">Apple</SelectItem>
      <SelectItem value="banana">Banana</SelectItem>
    </SelectContent>
  </Select>
</template>

With disabled items

Disable single items so users can see but not pick them.

<script setup lang="ts">
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/select';
</script>

<template>
  <Select>
    <SelectTrigger class="nx:w-[180px]" aria-label="Select an option">
      <SelectValue placeholder="Select option" />
    </SelectTrigger>
    <SelectContent>
      <SelectItem value="option1">Available</SelectItem>
      <SelectItem value="option2" disabled>Unavailable</SelectItem>
      <SelectItem value="option3">Available</SelectItem>
    </SelectContent>
  </Select>
</template>

With groups

Group items under labels and divide the groups with a separator.

<script setup lang="ts">
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
} from '@/components/select';
</script>

<template>
  <Select>
    <SelectTrigger class="nx:w-[200px]" aria-label="Select a food">
      <SelectValue placeholder="Select a food" />
    </SelectTrigger>
    <SelectContent>
      <SelectGroup>
        <SelectLabel>Fruits</SelectLabel>
        <SelectItem value="apple">Apple</SelectItem>
        <SelectItem value="banana">Banana</SelectItem>
        <SelectItem value="orange">Orange</SelectItem>
      </SelectGroup>
      <SelectSeparator />
      <SelectGroup>
        <SelectLabel>Vegetables</SelectLabel>
        <SelectItem value="carrot">Carrot</SelectItem>
        <SelectItem value="broccoli">Broccoli</SelectItem>
        <SelectItem value="spinach">Spinach</SelectItem>
      </SelectGroup>
    </SelectContent>
  </Select>
</template>

Sizes

Each trigger size matches the height of an Input at the same size.

<script setup lang="ts">
import { Input } from '@/components/input';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/select';

const sizes = ['sm', 'default', 'lg'] as const;
</script>

<template>
  <div class="nx:flex nx:w-[420px] nx:flex-col nx:gap-3">
    <div
      v-for="size in sizes"
      :key="size"
      class="nx:flex nx:items-center nx:gap-3"
    >
      <Select>
        <SelectTrigger :size="size" :aria-label="size + ' select'">
          <SelectValue :placeholder="size" />
        </SelectTrigger>
        <SelectContent>
          <SelectItem value="apple">Apple</SelectItem>
        </SelectContent>
      </Select>
      <Input :size="size" :placeholder="size" :aria-label="size + ' input'" />
    </div>
  </div>
</template>

This example also needs: Input.

Props

SelectContent

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

SelectItem

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

SelectLabel

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

SelectScrollDownButton

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

SelectScrollUpButton

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

SelectSeparator

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

SelectTrigger

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