Select
Displays a list of options for the user to pick from—triggered by a button.
Fruits
Apple
Banana
Blueberry
Grapes
Pineapple
import { css } from 'remix/component'
import {
Select,
SelectContent,
SelectGroup,import { css } from 'remix/component'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '#app/ui/select.tsx'
export function SelectDemo() {
return () => (
<Select>
<SelectTrigger mix={css({ width: '180px' })}>
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Fruits</SelectLabel>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="blueberry">Blueberry</SelectItem>
<SelectItem value="grapes">Grapes</SelectItem>
<SelectItem value="pineapple">Pineapple</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
)
}Installation
$ bunx --bun shadcn@latest add https://www.shadcn-remix.com/r/select.json$ npx shadcn@latest add https://www.shadcn-remix.com/r/select.json$ pnpm dlx shadcn@latest add https://www.shadcn-remix.com/r/select.json$ yarn dlx shadcn@latest add https://www.shadcn-remix.com/r/select.jsonInstall the following dependencies:
$ bun add remix$ npm install remix$ pnpm add remix$ yarn add remixCopy and paste the following code into your project.
import { css, on, ref, type Handle, type Props, type RemixNode } from 'remix/component'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from './icons.tsx'
import { fadeZoomByState, floatingLayer, slideInFromSide, type Align, type Side } from './popover.tsx'
// ---------------------------------------------------------------------------------------------
// Select (Radix Select semantics)
// ---------------------------------------------------------------------------------------------
/** Margin Radix keeps between item-aligned content and the viewport edge. */
const CONTENT_MARGIN = 10
/** Typeahead buffer reset delay, as in Radix. */
const TYPEAHEAD_RESET_MS = 1000
interface SelectContext {
readonly open: boolean
readonly value: string | undefined
readonly disabled: boolean
readonly contentId: string
trigger: HTMLElement | null
content: HTMLElement | null
/** Rendered contents of each item, so `SelectValue` can show the selected item like Radix. */
items: Map<string, RemixNode>
setOpen(open: boolean): void
select(value: string): void
/** Re-render after items registered on the client (resolves `SelectValue`). */
itemsChanged(): void
}
export interface SelectProps {
children?: RemixNode
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
name?: string
disabled?: boolean
required?: boolean
form?: string
autoComplete?: string
}
const bubbleSelectStyles = css({
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
borderWidth: 0,
})
export function Select(handle: Handle<SelectProps, SelectContext>) {
let uncontrolledValue = handle.props.defaultValue
let uncontrolledOpen = handle.props.defaultOpen ?? false
let itemsQueued = false
let context: SelectContext = {
get open() {
return handle.props.open ?? uncontrolledOpen
},
get value() {
let value = handle.props.value ?? uncontrolledValue
return value === '' ? undefined : value
},
get disabled() {
return handle.props.disabled ?? false
},
contentId: `${handle.id}-content`,
trigger: null,
content: null,
items: new Map(),
setOpen(open) {
if (open === context.open || (open && context.disabled)) return
if (handle.props.open === undefined) {
uncontrolledOpen = open
void handle.update()
}
handle.props.onOpenChange?.(open)
},
select(value) {
if (value !== context.value) {
if (handle.props.value === undefined) uncontrolledValue = value
handle.props.onValueChange?.(value)
}
context.setOpen(false)
void handle.update()
},
itemsChanged() {
if (itemsQueued) return
itemsQueued = true
handle.queueTask(() => {
itemsQueued = false
void handle.update()
})
},
}
handle.context.set(context)
return () => {
let { children, name, required, form, autoComplete, disabled } = handle.props
let value = context.value ?? ''
return (
<>
{children}
{/* Radix's "bubble select": a native select that carries the value into forms. */}
{name !== undefined || form !== undefined ? (
<select
aria-hidden="true"
tabIndex={-1}
name={name}
required={required}
disabled={disabled}
form={form}
autoComplete={autoComplete}
value={value}
mix={bubbleSelectStyles}
>
<option value="" />
{value ? <option value={value}>{value}</option> : null}
</select>
) : null}
</>
)
}
}
export function SelectGroup(handle: Handle<Props<'div'>>) {
return () => <div data-slot="select-group" role="group" {...handle.props} />
}
export interface SelectValueProps extends Props<'span'> {
placeholder?: RemixNode
}
export function SelectValue(handle: Handle<SelectValueProps>) {
let context = handle.context.get(Select)
return () => {
let { placeholder, children, ...props } = handle.props
let value = context.value
let content = children ?? (value !== undefined ? context.items.get(value) : undefined)
return (
<span data-slot="select-value" style={{ pointerEvents: 'none' }} {...props}>
{value === undefined ? placeholder : content}
</span>
)
}
}
// ---------------------------------------------------------------------------------------------
// Trigger
// ---------------------------------------------------------------------------------------------
export interface SelectTriggerProps extends Props<'button'> {
size?: 'sm' | 'default'
}
const triggerStyles = css({
display: 'flex',
width: 'fit-content',
alignItems: 'center',
justifyContent: 'space-between',
gap: '0.5rem',
borderRadius: 'calc(var(--radius) - 2px)',
borderWidth: '1px',
borderColor: 'var(--input)',
backgroundColor: 'transparent',
paddingInline: '0.75rem',
paddingBlock: '0.5rem',
fontSize: '0.875rem',
lineHeight: 'calc(1.25 / 0.875)',
whiteSpace: 'nowrap',
'--tw-shadow': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
boxShadow: 'var(--tw-ring-shadow), var(--tw-shadow)',
transitionProperty: 'color, box-shadow',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '150ms',
outlineStyle: 'none',
'&:focus-visible': {
borderColor: 'var(--ring)',
'--tw-ring-shadow':
'0 0 0 3px var(--tw-ring-color, color-mix(in oklab, var(--ring) 50%, transparent))',
},
'&:disabled': { cursor: 'not-allowed', opacity: 0.5 },
'&[aria-invalid="true"]': {
borderColor: 'var(--destructive)',
'--tw-ring-color': 'color-mix(in oklab, var(--destructive) 20%, transparent)',
},
'&[data-placeholder]': { color: 'var(--muted-foreground)' },
'&[data-size="default"]': { height: '2.25rem' },
'&[data-size="sm"]': { height: '2rem' },
'& > [data-slot="select-value"]': {
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: '1',
overflow: 'hidden',
},
'& > [data-slot="select-value"]:not(:empty)': { display: 'flex', alignItems: 'center', gap: '0.5rem' },
'.dark &': { backgroundColor: 'color-mix(in oklab, var(--input) 30%, transparent)' },
'.dark &:hover': { backgroundColor: 'color-mix(in oklab, var(--input) 50%, transparent)' },
'.dark &[aria-invalid="true"]': {
'--tw-ring-color': 'color-mix(in oklab, var(--destructive) 40%, transparent)',
},
'& svg': { pointerEvents: 'none', flexShrink: 0 },
'& svg:not([class*="size-"])': { width: '1rem', height: '1rem' },
'& svg:not([class*="text-"])': { color: 'var(--muted-foreground)' },
})
const triggerIconStyles = css({ width: '1rem', height: '1rem', opacity: 0.5 })
export function SelectTrigger(handle: Handle<SelectTriggerProps>) {
let context = handle.context.get(Select)
let search = createTypeahead()
let open = () => {
context.setOpen(true)
search.reset()
}
let behavior = [
ref<HTMLButtonElement>((node, signal) => {
context.trigger = node
signal.addEventListener('abort', () => {
if (context.trigger === node) context.trigger = null
})
}),
// Radix opens on mouse pointerdown so press-drag-release selects in one gesture.
on<HTMLButtonElement, 'pointerdown'>('pointerdown', (event) => {
if (event.pointerType !== 'mouse' || event.button !== 0 || event.ctrlKey) return
event.preventDefault()
event.currentTarget.focus()
open()
}),
on<HTMLButtonElement, 'click'>('click', (event) => {
// Touch and pen open on click; mouse already opened on pointerdown.
if ((event as PointerEvent).pointerType !== 'mouse') open()
}),
on<HTMLButtonElement, 'keydown'>('keydown', (event) => {
if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
// Closed-trigger typeahead selects the matching item directly.
if (event.key === ' ' && search.active()) return event.preventDefault()
if (event.key !== ' ') {
let match = search.next(event.key, itemElements(context.content), currentItem(context))
if (match?.dataset.value) context.select(match.dataset.value)
return
}
}
if (['Enter', ' ', 'ArrowUp', 'ArrowDown'].includes(event.key)) {
event.preventDefault()
open()
}
}),
]
return () => {
let { size = 'default', children, mix, ...props } = handle.props
return (
<button
type="button"
role="combobox"
data-slot="select-trigger"
data-size={size}
aria-controls={context.contentId}
aria-expanded={context.open ? 'true' : 'false'}
aria-autocomplete="none"
data-state={context.open ? 'open' : 'closed'}
data-placeholder={context.value === undefined ? '' : undefined}
disabled={context.disabled}
dir="ltr"
{...props}
mix={[triggerStyles, behavior, mix]}
>
{children}
<ChevronDownIcon aria-hidden="true" mix={triggerIconStyles} />
</button>
)
}
}
// ---------------------------------------------------------------------------------------------
// Content
// ---------------------------------------------------------------------------------------------
export interface SelectContentProps extends Props<'div'> {
position?: 'item-aligned' | 'popper'
side?: Side
align?: Align
sideOffset?: number
alignOffset?: number
}
const contentStyles = css({
position: 'relative',
zIndex: '50',
// Closed top-layer popovers rely on the UA `display: none`; only lay out while open.
'&:popover-open': { display: 'flex' },
flexDirection: 'column',
maxHeight: 'var(--radix-select-content-available-height)',
minWidth: '8rem',
transformOrigin: 'var(--radix-select-content-transform-origin)',
overflowX: 'hidden',
overflowY: 'auto',
borderRadius: 'calc(var(--radius) - 2px)',
borderWidth: '1px',
backgroundColor: 'var(--popover)',
color: 'var(--popover-foreground)',
'--tw-shadow': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
boxShadow: 'var(--tw-ring-shadow), var(--tw-shadow)',
outlineStyle: 'none',
...slideInFromSide,
...fadeZoomByState,
})
const popperStyles = css({
'&[data-side="bottom"]': { translate: '0 0.25rem' },
'&[data-side="left"]': { translate: '-0.25rem 0' },
'&[data-side="right"]': { translate: '0.25rem 0' },
'&[data-side="top"]': { translate: '0 -0.25rem' },
})
const viewportStyles = css({
position: 'relative',
flex: '1 1 0%',
overflow: 'hidden auto',
padding: '0.25rem',
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
})
const popperViewportStyles = css({
height: 'var(--radix-select-trigger-height)',
width: '100%',
minWidth: 'var(--radix-select-trigger-width)',
scrollMarginBlock: '0.25rem',
})
export function SelectContent(handle: Handle<SelectContentProps>) {
let context = handle.context.get(Select)
let search = createTypeahead()
let viewport: HTMLElement | null = null
let focusFrame = 0
// Focus the selected (or first) item once the content is shown, like Radix.
let focusSelected = () => {
cancelAnimationFrame(focusFrame)
focusFrame = requestAnimationFrame(() => {
let target = currentItem(context) ?? itemElements(context.content)[0]
target?.focus({ preventScroll: true })
target?.scrollIntoView({ block: 'nearest' })
})
}
handle.signal.addEventListener('abort', () => cancelAnimationFrame(focusFrame))
let keydown = on<HTMLDivElement, 'keydown'>('keydown', (event) => {
let items = itemElements(context.content)
let index = items.indexOf(document.activeElement as HTMLElement)
let focus = (item: HTMLElement | undefined) => {
if (!item) return
item.focus({ preventScroll: true })
item.scrollIntoView({ block: 'nearest' })
}
if (event.key === 'Tab') return event.preventDefault()
if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
if (event.key === ' ' && search.active()) return event.preventDefault()
if (event.key !== ' ') return focus(search.next(event.key, items, items[index]))
}
if (event.key === 'ArrowDown') {
event.preventDefault()
focus(items[Math.min(index + 1, items.length - 1)])
} else if (event.key === 'ArrowUp') {
event.preventDefault()
focus(items[index === -1 ? items.length - 1 : Math.max(index - 1, 0)])
} else if (event.key === 'Home' || event.key === 'PageUp') {
event.preventDefault()
focus(items[0])
} else if (event.key === 'End' || event.key === 'PageDown') {
event.preventDefault()
focus(items[items.length - 1])
} else if ((event.key === 'Enter' || event.key === ' ') && index !== -1) {
event.preventDefault()
let value = items[index]!.dataset.value
if (value !== undefined) context.select(value)
}
})
let wasOpen = false
return () => {
let {
position = 'item-aligned',
side = 'bottom',
align = 'center',
sideOffset = 0,
alignOffset = 0,
children,
mix,
...props
} = handle.props
let popper = position === 'popper'
if (context.open && !wasOpen) handle.queueTask(focusSelected)
wasOpen = context.open
return (
<div
data-slot="select-content"
id={context.contentId}
role="listbox"
tabIndex={-1}
dir="ltr"
data-state={context.open ? 'open' : 'closed'}
{...props}
mix={[
contentStyles,
popper ? popperStyles : null,
ref((node, signal) => {
context.content = node
signal.addEventListener('abort', () => {
if (context.content === node) context.content = null
})
}),
floatingLayer({
open: context.open,
anchor: () => context.trigger,
name: 'select',
side,
align: popper ? align : 'center',
sideOffset,
alignOffset,
position: popper ? undefined : (node) => alignToSelectedItem(node, context, () => viewport),
inside: (target) => !!context.trigger?.contains(target),
onDismiss: () => context.setOpen(false),
onClose: () => context.trigger?.focus({ preventScroll: true }),
}),
keydown,
on<HTMLDivElement, 'pointerleave'>('pointerleave', (event) => {
// Leaving the list clears the highlight, like Radix.
if (event.currentTarget.contains(document.activeElement)) event.currentTarget.focus()
}),
mix,
]}
>
<SelectScrollUpButton />
<div
data-slot="select-viewport"
role="presentation"
mix={[
viewportStyles,
popper ? popperViewportStyles : null,
ref((node) => {
viewport = node
}),
]}
>
{children}
</div>
<SelectScrollDownButton />
</div>
)
}
}
/**
* Radix `item-aligned` positioning: the selected item's text sits exactly over the trigger's
* value, the content is at least as wide as the trigger, and it stays `CONTENT_MARGIN` inside
* the viewport (scrolling the list when the item cannot be aligned).
*/
function alignToSelectedItem(
node: HTMLElement,
context: SelectContext,
getViewport: () => HTMLElement | null,
): () => void {
let place = () => {
let trigger = context.trigger
let viewport = getViewport()
if (!trigger || !viewport) return
let item = currentItem(context) ?? itemElements(node)[0]
let valueNode = trigger.querySelector<HTMLElement>('[data-slot="select-value"]') ?? trigger
let itemText = item?.querySelector<HTMLElement>('[data-slot="select-item-text"]') ?? item
node.style.position = 'fixed'
node.style.margin = '0'
node.style.maxHeight = `${window.innerHeight - CONTENT_MARGIN * 2}px`
// Horizontal: align item text with the trigger value.
let triggerRect = trigger.getBoundingClientRect()
let contentRect = node.getBoundingClientRect()
let valueRect = valueNode.getBoundingClientRect()
let itemTextOffset = itemText ? itemText.getBoundingClientRect().left - contentRect.left : 0
let left = valueRect.left - itemTextOffset
let minWidth = triggerRect.width + (triggerRect.left - left)
node.style.minWidth = `${minWidth}px`
let width = Math.max(minWidth, node.getBoundingClientRect().width)
let maxLeft = Math.max(CONTENT_MARGIN, window.innerWidth - CONTENT_MARGIN - width)
node.style.left = `${Math.min(Math.max(left, CONTENT_MARGIN), maxLeft)}px`
// Vertical: centre the selected item on the trigger.
let height = node.getBoundingClientRect().height
let itemMiddle = item
? node.clientTop + viewport.offsetTop + item.offsetTop - viewport.scrollTop + item.offsetHeight / 2
: height / 2
let top = triggerRect.top + triggerRect.height / 2 - itemMiddle
if (top < CONTENT_MARGIN) {
let scroll = Math.min(CONTENT_MARGIN - top, viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop)
viewport.scrollTop += Math.max(0, scroll)
top = CONTENT_MARGIN
}
top = Math.min(top, window.innerHeight - CONTENT_MARGIN - height)
node.style.top = `${Math.max(CONTENT_MARGIN, top)}px`
node.removeAttribute('data-side')
}
// Item-aligned content does not follow scrolling (Radix blocks page scroll while open), so
// place it once when shown and again if the window resizes.
let frame = requestAnimationFrame(place)
window.addEventListener('resize', place)
return () => {
cancelAnimationFrame(frame)
window.removeEventListener('resize', place)
}
}
// ---------------------------------------------------------------------------------------------
// Items
// ---------------------------------------------------------------------------------------------
export function SelectLabel(handle: Handle<Props<'div'>>) {
return () => {
let { mix, ...props } = handle.props
return <div data-slot="select-label" {...props} mix={[labelStyles, mix]} />
}
}
const labelStyles = css({
paddingInline: '0.5rem',
paddingBlock: '0.375rem',
fontSize: '0.75rem',
lineHeight: 'calc(1 / 0.75)',
color: 'var(--muted-foreground)',
})
export interface SelectItemProps extends Props<'div'> {
value: string
disabled?: boolean
/** Text used for typeahead when the item's content is not plain text. */
textValue?: string
}
const itemStyles = css({
position: 'relative',
display: 'flex',
width: '100%',
cursor: 'default',
alignItems: 'center',
gap: '0.5rem',
borderRadius: 'calc(var(--radius) - 4px)',
paddingBlock: '0.375rem',
paddingRight: '2rem',
paddingLeft: '0.5rem',
fontSize: '0.875rem',
lineHeight: 'calc(1.25 / 0.875)',
outline: '2px solid transparent',
outlineOffset: '2px',
userSelect: 'none',
'&:focus': { backgroundColor: 'var(--accent)', color: 'var(--accent-foreground)' },
'&[data-disabled]': { pointerEvents: 'none', opacity: 0.5 },
'& svg': { pointerEvents: 'none', flexShrink: 0 },
'& svg:not([class*="size-"])': { width: '1rem', height: '1rem' },
'& svg:not([class*="text-"])': { color: 'var(--muted-foreground)' },
'& > span:last-child': { display: 'flex', alignItems: 'center', gap: '0.5rem' },
})
const indicatorStyles = css({
position: 'absolute',
right: '0.5rem',
display: 'flex',
width: '0.875rem',
height: '0.875rem',
alignItems: 'center',
justifyContent: 'center',
})
const checkStyles = css({ width: '1rem', height: '1rem' })
export function SelectItem(handle: Handle<SelectItemProps>) {
let context = handle.context.get(Select)
let registered: string | undefined
handle.signal.addEventListener('abort', () => {
if (registered !== undefined) context.items.delete(registered)
})
let behavior = [
on<HTMLDivElement, 'pointermove'>('pointermove', (event) => {
if (!handle.props.disabled && document.activeElement !== event.currentTarget) {
event.currentTarget.focus({ preventScroll: true })
}
}),
on<HTMLDivElement, 'click'>('click', () => {
if (!handle.props.disabled) context.select(handle.props.value)
}),
]
return () => {
let { value, disabled, textValue, children, mix, ...props } = handle.props
if (context.items.get(value) !== children) {
if (registered !== undefined && registered !== value) context.items.delete(registered)
context.items.set(value, children)
registered = value
context.itemsChanged()
}
let selected = context.value === value
return (
<div
data-slot="select-item"
role="option"
tabIndex={disabled ? undefined : -1}
aria-selected={selected ? 'true' : 'false'}
aria-disabled={disabled ? 'true' : undefined}
data-state={selected ? 'checked' : 'unchecked'}
data-disabled={disabled ? '' : undefined}
data-value={value}
data-text-value={textValue}
{...props}
mix={[itemStyles, behavior, mix]}
>
<span data-slot="select-item-indicator" mix={indicatorStyles}>
{selected ? <CheckIcon mix={checkStyles} /> : null}
</span>
<span data-slot="select-item-text">{children}</span>
</div>
)
}
}
export function SelectSeparator(handle: Handle<Props<'div'>>) {
return () => {
let { mix, ...props } = handle.props
return <div data-slot="select-separator" aria-hidden="true" {...props} mix={[separatorStyles, mix]} />
}
}
const separatorStyles = css({
pointerEvents: 'none',
marginInline: '-0.25rem',
marginBlock: '0.25rem',
height: '1px',
backgroundColor: 'var(--border)',
})
// ---------------------------------------------------------------------------------------------
// Scroll buttons: shown only while the list can scroll that way; hovering scrolls it.
// ---------------------------------------------------------------------------------------------
const scrollButtonStyles = css({
display: 'flex',
cursor: 'default',
alignItems: 'center',
justifyContent: 'center',
paddingBlock: '0.25rem',
flexShrink: 0,
'&[hidden]': { display: 'none' },
})
function createScrollButton(direction: 'up' | 'down', slot: string) {
return function SelectScrollButton(handle: Handle<Props<'div'>>) {
let button: HTMLElement | null = null
let timer = 0
let stop = () => {
clearInterval(timer)
timer = 0
}
let viewportOf = () => button?.parentElement?.querySelector<HTMLElement>('[data-slot="select-viewport"]')
let sync = () => {
let viewport = viewportOf()
if (!button || !viewport) return
let canScroll =
direction === 'up'
? viewport.scrollTop > 0
: Math.ceil(viewport.scrollTop) < viewport.scrollHeight - viewport.clientHeight
button.hidden = !canScroll
if (!canScroll) stop()
}
handle.signal.addEventListener('abort', stop)
return () => {
let { mix, ...props } = handle.props
return (
<div
data-slot={slot}
aria-hidden="true"
hidden
{...props}
mix={[
scrollButtonStyles,
ref((node, signal) => {
button = node
let viewport = viewportOf()
if (!viewport) return
viewport.addEventListener('scroll', sync, { signal })
let observer = new ResizeObserver(sync)
observer.observe(viewport)
signal.addEventListener('abort', () => observer.disconnect())
}),
on('pointerenter', () => {
if (timer) return
timer = window.setInterval(() => {
let viewport = viewportOf()
let item = viewport?.querySelector<HTMLElement>('[role="option"]')
viewport?.scrollBy({ top: (direction === 'up' ? -1 : 1) * (item?.offsetHeight ?? 32) })
}, 50)
}),
on('pointerleave', stop),
mix,
]}
>
{direction === 'up' ? <ChevronUpIcon /> : <ChevronDownIcon />}
</div>
)
}
}
}
export const SelectScrollUpButton = createScrollButton('up', 'select-scroll-up-button')
export const SelectScrollDownButton = createScrollButton('down', 'select-scroll-down-button')
// ---------------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------------
function itemElements(content: HTMLElement | null): HTMLElement[] {
if (!content) return []
return [...content.querySelectorAll<HTMLElement>('[role="option"]:not([data-disabled])')]
}
function currentItem(context: SelectContext): HTMLElement | undefined {
let value = context.value
if (value === undefined) return undefined
return itemElements(context.content).find((item) => item.dataset.value === value)
}
/**
* Radix typeahead: typed characters accumulate for `TYPEAHEAD_RESET_MS`; repeating one character
* cycles through items starting with it, starting after the current item.
*/
function createTypeahead() {
let buffer = ''
let timer = 0
return {
active: () => buffer !== '',
reset() {
buffer = ''
clearTimeout(timer)
},
next(key: string, items: HTMLElement[], current: HTMLElement | undefined) {
buffer += key.toLowerCase()
clearTimeout(timer)
timer = window.setTimeout(() => (buffer = ''), TYPEAHEAD_RESET_MS)
let isRepeated = buffer.length > 1 && [...buffer].every((char) => char === buffer[0])
let search = isRepeated ? buffer[0]! : buffer
let start = current ? items.indexOf(current) : -1
let ordered = [...items.slice(start + 1), ...items.slice(0, start + 1)]
if (!isRepeated && current) ordered = [current, ...ordered.filter((item) => item !== current)]
let textOf = (item: HTMLElement) =>
(item.dataset.textValue ?? item.querySelector('[data-slot="select-item-text"]')?.textContent ?? '')
.trim()
.toLowerCase()
return ordered.find((item) => textOf(item).startsWith(search))
},
}
}Update the import paths to match your project setup.
Usage
import { css } from 'remix/component'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '#app/ui/select.tsx'<Select>
<SelectTrigger mix={css({ width: '180px' })}>
<SelectValue placeholder="Theme" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectGroup>
</SelectContent>
</Select>Examples
Scrollable
A select with many items that scrolls.
North America
Eastern Standard Time (EST)
Central Standard Time (CST)
Mountain Standard Time (MST)
Pacific Standard Time (PST)
Alaska Standard Time (AKST)
Hawaii Standard Time (HST)
Europe & Africa
Greenwich Mean Time (GMT)
Central European Time (CET)
Eastern European Time (EET)
Western European Summer Time (WEST)
Central Africa Time (CAT)
East Africa Time (EAT)
Asia
Moscow Time (MSK)
India Standard Time (IST)
China Standard Time (CST)
Japan Standard Time (JST)
Korea Standard Time (KST)
Indonesia Central Standard Time (WITA)
Australia & Pacific
Australian Western Standard Time (AWST)
Australian Central Standard Time (ACST)
Australian Eastern Standard Time (AEST)
New Zealand Standard Time (NZST)
Fiji Time (FJT)
South America
Argentina Time (ART)
Bolivia Time (BOT)
Brasilia Time (BRT)
Chile Standard Time (CLT)
import { css } from 'remix/component'
import {
Select,
SelectContent,
SelectGroup,import { css } from 'remix/component'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '#app/ui/select.tsx'
export function SelectScrollable() {
return () => (
<Select>
<SelectTrigger mix={css({ width: '280px' })}>
<SelectValue placeholder="Select a timezone" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>North America</SelectLabel>
<SelectItem value="est">Eastern Standard Time (EST)</SelectItem>
<SelectItem value="cst">Central Standard Time (CST)</SelectItem>
<SelectItem value="mst">Mountain Standard Time (MST)</SelectItem>
<SelectItem value="pst">Pacific Standard Time (PST)</SelectItem>
<SelectItem value="akst">Alaska Standard Time (AKST)</SelectItem>
<SelectItem value="hst">Hawaii Standard Time (HST)</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Europe & Africa</SelectLabel>
<SelectItem value="gmt">Greenwich Mean Time (GMT)</SelectItem>
<SelectItem value="cet">Central European Time (CET)</SelectItem>
<SelectItem value="eet">Eastern European Time (EET)</SelectItem>
<SelectItem value="west">
Western European Summer Time (WEST)
</SelectItem>
<SelectItem value="cat">Central Africa Time (CAT)</SelectItem>
<SelectItem value="eat">East Africa Time (EAT)</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Asia</SelectLabel>
<SelectItem value="msk">Moscow Time (MSK)</SelectItem>
<SelectItem value="ist">India Standard Time (IST)</SelectItem>
<SelectItem value="cst_china">China Standard Time (CST)</SelectItem>
<SelectItem value="jst">Japan Standard Time (JST)</SelectItem>
<SelectItem value="kst">Korea Standard Time (KST)</SelectItem>
<SelectItem value="ist_indonesia">
Indonesia Central Standard Time (WITA)
</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Australia & Pacific</SelectLabel>
<SelectItem value="awst">
Australian Western Standard Time (AWST)
</SelectItem>
<SelectItem value="acst">
Australian Central Standard Time (ACST)
</SelectItem>
<SelectItem value="aest">
Australian Eastern Standard Time (AEST)
</SelectItem>
<SelectItem value="nzst">New Zealand Standard Time (NZST)</SelectItem>
<SelectItem value="fjt">Fiji Time (FJT)</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>South America</SelectLabel>
<SelectItem value="art">Argentina Time (ART)</SelectItem>
<SelectItem value="bot">Bolivia Time (BOT)</SelectItem>
<SelectItem value="brt">Brasilia Time (BRT)</SelectItem>
<SelectItem value="clt">Chile Standard Time (CLT)</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
)
}API Reference
Select
| Prop | Type | Default |
|---|---|---|
value | string | - |
defaultValue | string | - |
onValueChange | ((value: string) => void) | - |
open | boolean | - |
defaultOpen | boolean | - |
onOpenChange | ((open: boolean) => void) | - |
name | string | - |
disabled | boolean | - |
required | boolean | - |
form | string | - |
autoComplete | string | - |
SelectGroup
| Prop | Type | Default |
|---|---|---|
mix | MixValue | - |
SelectValue
| Prop | Type | Default |
|---|---|---|
placeholder | RemixNode | - |
mix | MixValue | - |
SelectTrigger
| Prop | Type | Default |
|---|---|---|
size | "default" | "sm" | "default" |
mix | MixValue | - |
SelectContent
| Prop | Type | Default |
|---|---|---|
position | "item-aligned" | "popper" | "item-aligned" |
side | Side | "bottom" |
align | Align | "center" |
sideOffset | number | 0 |
alignOffset | number | 0 |
mix | MixValue | - |
SelectLabel
| Prop | Type | Default |
|---|---|---|
mix | MixValue | - |
SelectItem
| Prop | Type | Default |
|---|---|---|
value | string | - |
disabled | boolean | - |
textValue | string | - |
mix | MixValue | - |
SelectSeparator
| Prop | Type | Default |
|---|---|---|
mix | MixValue | - |