Component
Native Select
Genuine native select element styled to match the DevSnips select language.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/selects/native-select/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Selects/native-select React/Components/Selects/native-select import type { ChangeEvent, ReactNode } from "react";
import { useId, useState } from "react";
export type SelectSize = "sm" | "md" | "lg";
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
export interface NativeSelectProps {
label?: string;
helperText?: string;
error?: string;
success?: string;
options: SelectOption[];
value?: string;
defaultValue?: string;
onChange?: (value: string, option: SelectOption) => void;
size?: SelectSize;
placeholder?: string;
disabled?: boolean;
leadingIcon?: ReactNode;
id?: string;
className?: string;
name?: string;
}
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
const SIZES: Record<SelectSize, string> = {
sm: "h-8 text-[13px] [&_svg]:size-[14px]",
md: "h-9 text-sm [&_svg]:size-4",
lg: "h-11 text-sm [&_svg]:size-[18px]",
};
function ChevronDown({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
);
}
/**
* Native HTML <select> styled to match the custom select visual language.
* Uses the real native control (full keyboard support, native OS menu,
* native form participation via `name`) but visually consistent with the
* custom listbox select: same height/border/radius/tokens, a hidden native
* arrow via `appearance-none`, and an absolutely-positioned chevron SVG
* overlay that mirrors the custom variant. Controlled (value/onChange) and
* uncontrolled (defaultValue) modes both supported; onChange receives the
* chosen value plus the matching option object. A `placeholder` is
* rendered as a disabled first <option> when nothing is selected.
*/
export function NativeSelect({
label = "Select",
helperText,
error,
success,
options,
value,
defaultValue = "",
onChange,
size = "md",
placeholder = "Select an option",
disabled,
leadingIcon,
id,
className,
name,
}: NativeSelectProps) {
const generatedId = useId();
const selectId = id ?? `native-select-${generatedId}`;
const messageId = `${selectId}-message`;
const message = error ?? success ?? helperText;
const [internalValue, setInternalValue] = useState(defaultValue);
const selectedValue = value ?? internalValue;
const selected = options.find((o) => o.value === selectedValue) ?? null;
function handleChange(event: ChangeEvent<HTMLSelectElement>) {
const next = event.target.value;
const option = options.find((o) => o.value === next) ?? { value: next, label: next };
if (value === undefined) setInternalValue(next);
onChange?.(next, option);
}
return (
<div className="w-full">
<label
htmlFor={selectId}
className={cx(
"mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]",
disabled && "text-[var(--ds-color-muted-foreground)]",
)}
>
{label}
</label>
{message ? (
<p
id={messageId}
className={cx(
"mb-2 text-xs",
error ? "text-[var(--ds-color-destructive)]" : success ? "text-[var(--ds-color-success)]" : "text-[var(--ds-color-muted-foreground)]",
)}
>
{error ? `Error: ${error}` : success ? `Success: ${success}` : helperText}
</p>
) : null}
<div className="relative">
{leadingIcon ? (
<span className="pointer-events-none absolute left-3 z-10 text-[var(--ds-color-muted-foreground)]">{leadingIcon}</span>
) : null}
<select
id={selectId}
name={name}
value={selectedValue}
defaultValue={value === undefined ? defaultValue : undefined}
onChange={handleChange}
disabled={disabled}
aria-invalid={error ? true : undefined}
aria-describedby={message ? messageId : undefined}
className={cx(
"w-full appearance-none rounded-[var(--ds-radius-sm)] border bg-[var(--ds-color-input)] pr-9 text-[var(--ds-color-foreground)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-input-hover,var(--ds-color-input))] focus:bg-[var(--ds-color-input-focus,var(--ds-color-input))] focus:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:bg-[var(--ds-color-muted)] disabled:text-[var(--ds-color-muted-foreground)] disabled:opacity-60 motion-reduce:transition-none",
error ? "border-[var(--ds-color-destructive)]" : success ? "border-[var(--ds-color-success)]" : "border-[var(--ds-color-border)]",
SIZES[size],
leadingIcon ? "pl-9" : "pl-3",
className,
)}
>
{!selected ? (
<option value="" disabled>
{placeholder}
</option>
) : null}
{options.map((option) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
<ChevronDown
className={cx(
"pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 shrink-0 text-[var(--ds-color-muted-foreground)] transition-transform duration-150 motion-reduce:transition-none",
disabled && "opacity-60",
)}
/>
</div>
</div>
);
}
export default NativeSelect; /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import { useId, useState } from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const SIZES = {
sm: "h-8 text-[13px] [&_svg]:size-[14px]",
md: "h-9 text-sm [&_svg]:size-4",
lg: "h-11 text-sm [&_svg]:size-[18px]"
};
function ChevronDown({ className }) {
return <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>;
}
export function NativeSelect({
label = "Select",
helperText,
error,
success,
options,
value,
defaultValue = "",
onChange,
size = "md",
placeholder = "Select an option",
disabled,
leadingIcon,
id,
className,
name
}) {
const generatedId = useId();
const selectId = id ?? `native-select-${generatedId}`;
const messageId = `${selectId}-message`;
const message = error ?? success ?? helperText;
const [internalValue, setInternalValue] = useState(defaultValue);
const selectedValue = value ?? internalValue;
const selected = options.find((o) => o.value === selectedValue) ?? null;
function handleChange(event) {
const next = event.target.value;
const option = options.find((o) => o.value === next) ?? { value: next, label: next };
if (value === undefined) setInternalValue(next);
onChange?.(next, option);
}
return <div className="w-full">
<label
htmlFor={selectId}
className={cx(
"mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]",
disabled && "text-[var(--ds-color-muted-foreground)]"
)}
>
{label}
</label>
{message ? <p
id={messageId}
className={cx(
"mb-2 text-xs",
error ? "text-[var(--ds-color-destructive)]" : success ? "text-[var(--ds-color-success)]" : "text-[var(--ds-color-muted-foreground)]"
)}
>
{error ? `Error: ${error}` : success ? `Success: ${success}` : helperText}
</p> : null}
<div className="relative">
{leadingIcon ? <span className="pointer-events-none absolute left-3 z-10 text-[var(--ds-color-muted-foreground)]">{leadingIcon}</span> : null}
<select
id={selectId}
name={name}
value={selectedValue}
defaultValue={value === undefined ? defaultValue : undefined}
onChange={handleChange}
disabled={disabled}
aria-invalid={error ? true : undefined}
aria-describedby={message ? messageId : undefined}
className={cx(
"w-full appearance-none rounded-[var(--ds-radius-sm)] border bg-[var(--ds-color-input)] pr-9 text-[var(--ds-color-foreground)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-input-hover,var(--ds-color-input))] focus:bg-[var(--ds-color-input-focus,var(--ds-color-input))] focus:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:bg-[var(--ds-color-muted)] disabled:text-[var(--ds-color-muted-foreground)] disabled:opacity-60 motion-reduce:transition-none",
error ? "border-[var(--ds-color-destructive)]" : success ? "border-[var(--ds-color-success)]" : "border-[var(--ds-color-border)]",
SIZES[size],
leadingIcon ? "pl-9" : "pl-3",
className
)}
>
{!selected ? <option value="" disabled>
{placeholder}
</option> : null}
{options.map((option) => <option key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</option>)}
</select>
<ChevronDown
className={cx(
"pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 shrink-0 text-[var(--ds-color-muted-foreground)] transition-transform duration-150 motion-reduce:transition-none",
disabled && "opacity-60"
)}
/>
</div>
</div>;
}
export default NativeSelect; # Native Select
Genuine native select element styled to match the DevSnips select language.
## Usage
```tsx
import { NativeSelect } from './code';
<NativeSelect label="Environment" options={[{value:"production",label:"Production"},{value:"staging",label:"Staging"},{value:"development",label:"Development"}]} defaultValue="production" />
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
import { NativeSelect } from './code';
<NativeSelect label="Environment" options={[{value:"production",label:"Production"},{value:"staging",label:"Staging"},{value:"development",label:"Development"}]} defaultValue="production" />
```
## Props
| Name | Type | Default | Description |
|---|---|---:|---|
| `label` | `string` | `"Select"` | Visible label. |
| `options` | `{value,label,disabled?}[]` | — | Option list. |
| `value` / `defaultValue` | `string` | — | Controlled / uncontrolled value. |
| `onChange` | `(value, option) => void` | — | Selection callback. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Control height. |
| `placeholder` | `string` | `"Select an option"` | First disabled option. |
| `disabled` | `boolean` | — | Disables the select. |
| native `<select>` attrs | — | — | `name`, `id`, `aria-*`. |
## Behavior
Uses the browser's native `<select>` for full native behavior (form submission, mobile picker, platform conventions). A chevron overlay is positioned over the native control to match the custom select visual language.
## Accessibility
Native `<select>` + `<option>` elements are accessible by default. A visible `<label htmlFor>` associates the field; `aria-invalid` and `aria-describedby` wire error/helper text.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-input)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This select uses the semantic color, radius, spacing, and motion tokens.
## Notes
Use native-select when you need native form semantics and platform pickers (especially mobile). Use the custom `select` when you need custom interaction or styling the native control cannot provide. 157 lines UTF-8 · LF · Spaces: 2
Continue browsing