Select
A searchable single-select. It reads options from any array of objects through valueKey and labelKey, so API responses go in unmapped.
'use client'
import * as React from 'react'
import { Select } from '@/components/custom/select'
const EMPLOYEES = [
{ id: 'u1', firstName: 'Ada', lastName: 'Lovelace', role: 'Engineering' },
{ id: 'u2', firstName: 'Grace', lastName: 'Hopper', role: 'Engineering' },
{ id: 'u3', firstName: 'Wanjiru', lastName: 'Kamau', role: 'Finance' },
{ id: 'u4', firstName: 'Otieno', lastName: 'Odhiambo', role: 'Operations' },
{ id: 'u5', firstName: 'Amina', lastName: 'Yusuf', role: 'People' },
]
export default function SelectDemo() {
const [approver, setApprover] = React.useState<string>()
return (
<div className="w-full max-w-sm space-y-3">
<Select
label="Approver"
required
data={EMPLOYEES}
value={approver}
onChange={setApprover}
valueKey="id"
// A composite label reads the two name fields without pre-mapping.
labelKey={['firstName', 'lastName']}
placeholder="Choose who signs this off"
searchPlaceholder="Search employees..."
/>
<p className="text-sm text-muted-foreground">
Stored value: <code className="font-mono">{approver ?? 'undefined'}</code>
</p>
</div>
)
}Installation
pnpm dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/select.jsonnpx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/select.jsonyarn dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/select.jsonbunx --bun shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/select.jsonInstalls components/custom/select.tsx, the shadcn command and popover primitives, and form-field. It does not replace @/components/ui/select, which remains available for short static lists.
Usage
'use client'
import { Select } from '@/components/custom/select'
export function BranchField({ branches, value, onChange }) {
return (
<Select
label="Branch"
required
data={branches}
value={value}
onChange={onChange}
valueKey="id"
labelKey="name"
placeholder="Choose a branch"
/>
)
}Composite labels
labelKey accepts an array, joined with spaces — no need to map the list first.
<Select data={employees} valueKey="id" labelKey={['firstName', 'lastName']} label="Employee" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
data | FieldOption[] | [] | Options. Any object shape. |
value | unknown | — | Matched against option[valueKey]. |
onChange | (value: any) => void | — | Receives option[valueKey]. |
valueKey | string | 'value' | Property read for the stored value. |
labelKey | string | string[] | 'label' | Property or properties read for the label. |
label | string | — | Required. |
placeholder | string | 'Select an option...' | Shown while nothing is selected. |
searchPlaceholder | string | 'Search...' | Placeholder inside the filter box. |
disabled | boolean | false | Blocks opening. |
error | boolean | false | Switches the border and ring to the danger ramp. |
errorMessage | ReactNode | — | Shown only while error is true. |
Behaviour notes
Why not a native <select>. It stops being usable somewhere around fifty options, and every list this exists for — employees, branches, cost centres — is past that. The search box is the feature; the popover is what makes room for it.
Search matches the label, never the id. Nobody searches by uuid, so the Command value is the rendered label.
Scrolling stays inside the list. Wheel and touch events stop propagating once the list itself can scroll, which keeps a gesture over the options from scrolling the page — or closing a dialog that dismisses on scroll.
The trigger is a real <button>, so it is focusable, activates on Enter and Space for free, and is announced as a combobox.