# shadcn's select shows invalid state on the trigger, so wire validation there

> shadcn's select shows its invalid state on the trigger. Here is the field wiring, a submit check, and the keyboard and phone tests in React.
> By Dave · 2026-10-13
> Source: https://otf-kit.dev/blog/shadcn-select-howto

A native select is a browser control, so validation rules attach to it directly. A shadcn select is a styled trigger with a list behind it, so the error has to be wired by hand: `data-invalid` on the field and `aria-invalid` on the trigger.

## What the shadcn page covers

The page describes the component as "Displays a list of options for the user to pick from, triggered by a button." The install command is `pnpm dlx shadcn@latest add select`, with tabs for npm, yarn and bun. The usage example imports `Select`, `SelectContent`, `SelectGroup`, `SelectItem`, `SelectTrigger` and `SelectValue` from `@/components/ui/select`. The composition section adds `SelectLabel` and `SelectSeparator`.

The examples cover align item with trigger, groups, a scrollable list, a disabled state, an invalid state and a right-to-left layout. The invalid example is the one this article needs. It imports `Field`, `FieldError` and `FieldLabel` from `@/components/ui/field`.

The page says nothing about native selects or general form validation. The sidebar lists Native Select as its own component, but this page does not compare the two. The rules below are ours, and they are labelled as ours.

## Why the error goes on the trigger

The trigger is the control the user focuses. When a select is in an error state, the error belongs on that control, not on the list that opens from it. The page's invalid example follows this pattern: the wrapper `Field` gets `data-invalid`, and the `SelectTrigger` gets `aria-invalid`. The example shows the message "Please select a fruit." through `FieldError`.

Put the two attributes on their own elements, and do not replace one with the other. The field attribute marks the whole group, including its label and message. The trigger attribute marks the control itself, which is what a screen reader announces when the user lands on it.

## Build the field

Start with the label, the select and the error message inside one `Field`. Keep the select's value in React state, so you can check it on submit:

```tsx
import {
  Field,
  FieldError,
  FieldLabel,
} from "@/components/ui/field"
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

const fruits = ["Apple", "Banana", "Cherry"]

export function FruitField({
  value,
  onChange,
  error,
}: {
  value: string | null
  onChange: (value: string) => void
  error: string | null
}) {
  return (
    <Field data-invalid={error ? true : undefined}>
      <FieldLabel>Favorite fruit</FieldLabel>
      <Select value={value} onValueChange={onChange}>
        <SelectTrigger aria-invalid={error ? true : undefined}>
          <SelectValue placeholder="Select a fruit" />
        </SelectTrigger>
        <SelectContent>
          <SelectGroup>
            {fruits.map((fruit) => (
              <SelectItem key={fruit} value={fruit}>
                {fruit}
              </SelectItem>
            ))}
          </SelectGroup>
        </SelectContent>
      </Select>
      {error && <FieldError>{error}</FieldError>}
    </Field>
  )
}
```

The page's invalid example shows the two attributes, not the `value` and `onValueChange` props on the root. The root props above follow the usual pattern for a controlled select. Check the example's View Code panel before you copy the root, because those props are collapsed on the page.

![Luna taps one row of a floating list of options](https://cdn.otf-kit.dev/blog/shadcn-select-howto/inbody1-20261013.png)

## Validate on submit

Our approach keeps validation in the form, not in the select. Check the value when the user submits, set the error text, and clear it once a choice exists:

```tsx
const [fruit, setFruit] = React.useState<string | null>(null)
const [error, setError] = React.useState<string | null>(null)

function onSubmit() {
  if (!fruit) {
    setError("Please select a fruit.")
    return
  }
  setError(null)
  saveFavorite(fruit) // your save call
}
```

Pass `fruit` to the field as `value`, `setFruit` as the change handler through `onChange`, and `error` as the message. When the user picks an option, the error clears on the next submit. If you want it to clear on the pick itself, call `setError(null)` inside the change handler. Choose one behavior and keep it across the form, so users learn what to expect.

## Groups and long lists

The page lists a groups example, and its composition includes `SelectLabel` for group headings. Use groups when the options fall into clear sections, such as regions or plan tiers. A flat list of five options does not need them.

The page also lists a scrollable example for a longer list that still opens as one select. Past a point, though, a searchable list is the better control. For that case, see the [combobox guide](/blog/shadcn-combobox-howto), which covers the searchable select.

![Dex and Luna at a split panel: a closed select on one side, a field with an error glow on the other](https://cdn.otf-kit.dev/blog/shadcn-select-howto/inbody2-20261013.png)

## Native select or shadcn select

This is our advice, not the docs'. A native select opens the phone's own picker on most mobile devices, which many users already know. For a short form on a phone, a native select can be the simpler choice. Use the shadcn select when the form already uses the other shadcn controls and the design needs one visual language across the page, or when you need the groups, the scrollable list or the invalid state shown in the page's examples.

Whichever you choose, keep the label and the error message in the same place for every select on the page. A form that mixes a styled select and a native one with different error styles is harder to scan.

## Test before you ship

Test the keyboard and the error state together, because they interact:

- Tab to the trigger. The focus ring should show on the trigger, not on the list.
- Press Enter or Space to open the list, then use the arrow keys to move through the options.
- Press Escape. The list should close and focus should return to the trigger.
- Submit with nothing chosen. The error should show under the field, and the trigger should carry the invalid state.
- Choose an option, then submit again. The error should clear and the form should save.
- On a 390px phone, check that the open list fits the screen and the error message does not push the submit button out of reach.

Run the phone check last, after the wording is final. A long option label can wrap inside the trigger and move the error text down.

## Where OTF stands

If you want a select already built into a product, we cannot point you to one. We checked the SaaS Dashboard, Booking, Fitness and Arcade kit pages for select, combobox and dropdown terms, and none of them lists a select component. So this article does not say any OTF kit ships one. The pricing page lists the Full-stack kits at $99 each and the Everything Bundle at $149, both as one-time payments, and it offers a live demo before you buy. Check each kit's repository for the components it ships before you rely on any of them.

If you are choosing a starting point for a product, the pricing page is the place to compare kits side by side: [OTF pricing](/pricing). For the dialog that often holds a select in a settings form, see the [dialog and sheet guide](/blog/shadcn-dialog-vs-sheet-howto).

## Sources

- shadcn/ui, Select: https://ui.shadcn.com/docs/components/select
- OTF pricing: [otf-kit.dev/pricing](/pricing)
