All components
Native Select
A styled platform <select> with a positioned chevron indicator across five sizes.
12345678910111213141516import { NativeSelect } from "#components/ui/base-ui/native-select.tsx";
export function Example() { return ( // variant/size resolve once on Root; parts inherit them via context and // expose data-variant / data-size — the Indicator ships a default chevron <NativeSelect.Root variant="outline" size="md"> <NativeSelect.Field aria-label="Plan" defaultValue="Pro"> <option>Free</option> <option>Pro</option> <option>Enterprise</option> </NativeSelect.Field> <NativeSelect.Indicator /> </NativeSelect.Root> );}Variants
outline
subtle
plain
Sizes
xs
sm
md
lg
xl
Install
1npx stylexblocks add native-selectSource
components/ui/base-ui/native-select.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238import * as stylex from "@stylexjs/stylex";import { createSlotRecipeContext } from "#lib/base-ui/create-slot-recipe-context.tsx";import { defineSlotRecipe } from "#lib/recipe-props.ts";import { nativeSelectIndicatorIconVars } from '@stylexblocks/ui/slot-recipes/native-select.stylex.ts'
import { colorPalette } from '@stylexblocks/ui/color-palette.stylex.ts'import { fontSizes } from '@stylexblocks/ui/tokens/font-sizes.stylex.ts'import { lineHeights } from '@stylexblocks/ui/tokens/line-heights.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { semanticRadii } from '@stylexblocks/ui/semantic-tokens/radii.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'
const nativeSelectRoot = stylex.create({ base: { height: 'fit-content', display: 'flex', width: '100%', position: 'relative', },})
const nativeSelectField = stylex.create({ base: { width: '100%', minWidth: 0, outline: 'none', appearance: 'none', borderRadius: semanticRadii.control, opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, cursor: { default: null, ':is(:disabled, [disabled], [data-disabled])': 'not-allowed', }, borderColor: { default: null, ':is([data-invalid], [aria-invalid="true"])': semanticColors.borderError, }, outlineWidth: { default: null, ':focus-visible': 'var(--focus-ring-width, 0)', }, outlineOffset: { default: null, ':focus-visible': '0px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': colorPalette.focusRing, }, lineHeight: 'normal', },})
const nativeSelectFieldSizes = stylex.create({ xs: { fontSize: fontSizes.xs, lineHeight: lineHeights.xs, paddingInlineStart: spacing._2, paddingInlineEnd: spacing._6, height: sizes.controlXs, }, sm: { fontSize: fontSizes.sm, lineHeight: lineHeights.sm, paddingInlineStart: spacing._2_5, paddingInlineEnd: spacing._8, height: sizes.controlSm, }, md: { fontSize: fontSizes.sm, lineHeight: lineHeights.sm, paddingInlineStart: spacing._3, paddingInlineEnd: spacing._8, height: sizes.controlMd, }, lg: { fontSize: fontSizes.md, lineHeight: lineHeights.md, paddingInlineStart: spacing._4, paddingInlineEnd: spacing._8, height: sizes.controlLg, }, xl: { fontSize: fontSizes.md, lineHeight: lineHeights.md, paddingInlineStart: spacing._4_5, paddingInlineEnd: spacing._10, height: sizes.controlXl, },})
const nativeSelectFieldVariants = stylex.create({ outline: { backgroundColor: semanticColors.bg, borderWidth: '1px', borderColor: { default: semanticColors.border, ':is([data-invalid], [aria-invalid="true"])': semanticColors.borderError, }, }, subtle: { borderWidth: '1px', borderColor: { default: 'transparent', ':is([data-invalid], [aria-invalid="true"])': semanticColors.borderError, }, backgroundColor: semanticColors.bgMuted, }, plain: { backgroundColor: 'transparent', color: semanticColors.fg, focusRingWidth: '2px', },})
const nativeSelectIndicator = stylex.create({ base: { position: 'absolute', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none', top: '50%', transform: 'translateY(-50%)', height: '100%', color: { default: semanticColors.fgMuted, ':is([data-invalid], [aria-invalid="true"])': semanticColors.fgError, }, opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, [nativeSelectIndicatorIconVars.width]: '1em', [nativeSelectIndicatorIconVars.height]: '1em', },})
const nativeSelectIndicatorSizes = stylex.create({ xs: { fontSize: fontSizes.sm, lineHeight: lineHeights.sm, insetInlineEnd: spacing._1_5, }, sm: { fontSize: fontSizes.md, lineHeight: lineHeights.md, insetInlineEnd: spacing._2, }, md: { fontSize: fontSizes.lg, lineHeight: lineHeights.lg, insetInlineEnd: spacing._2, }, lg: { fontSize: fontSizes.xl, lineHeight: lineHeights.xl, insetInlineEnd: spacing._3, }, xl: { fontSize: fontSizes.xl, lineHeight: lineHeights.xl, insetInlineEnd: spacing._3, },})
const nativeSelectIndicatorIcon = stylex.create({ base: { width: nativeSelectIndicatorIconVars.width, height: nativeSelectIndicatorIconVars.height, },})
export type NativeSelectVariant = keyof typeof nativeSelectFieldVariants
export type NativeSelectSize = keyof typeof nativeSelectFieldSizes
// defineSlotRecipe validates the shape at compile time: default values must// exist in the axis maps, and slot keys must be `styles` or a declared axisconst nativeSelectSlotRecipe = defineSlotRecipe({ slots: { root: { styles: nativeSelectRoot }, field: { styles: nativeSelectField, sizes: nativeSelectFieldSizes, variants: nativeSelectFieldVariants, }, indicator: { styles: nativeSelectIndicator, sizes: nativeSelectIndicatorSizes }, }, defaultVariants: { variant: "outline", size: "md" },});
function ChevronDownIcon(props: React.ComponentProps<"svg">) { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}> <path d="m4 6 4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> </svg> );}
/** * NativeSelect — a styled platform `<select>`. `<NativeSelect.Root variant * size>` resolves the recipe once; Field and Indicator inherit through * context (exposed as data-variant / data-size). The Indicator renders a * chevron by default; pass children to replace it. Composition per part via * `render`. */const { withProvider, withContext } = createSlotRecipeContext(nativeSelectSlotRecipe);
const IndicatorFrame = withContext( "div", (v) => [nativeSelectIndicator.base, nativeSelectIndicatorSizes[v.size]], "NativeSelectIndicator",);
function Indicator({ children, ...props }: React.ComponentProps<typeof IndicatorFrame>) { return ( <IndicatorFrame {...props}> {children ?? <ChevronDownIcon {...stylex.props(nativeSelectIndicatorIcon.base)} />} </IndicatorFrame> );}
export const NativeSelect = { Root: withProvider("div", "root", "NativeSelectRoot"), Field: withContext("select", "field", "NativeSelectField"), Indicator,};