@alsocoder/apna-select
v0.2.3
Published
A flexible React select component with single/multi select, search, async API loading, and CSS customization.
Maintainers
Readme
@alsocoder/apna-select
A flexible React select component with single/multi select, search, and async API loading.
Features
- Single and multi select
- Client-side and server-side search
- Controlled and uncontrolled modes
- Async data loading via
urlorloadOptions - Native form support with hidden inputs (
nameprop) - Fully customizable via
className,classNames, and CSS variables - Built on Radix UI Popover + Checkbox
- Keyboard navigation (Arrow keys, Enter, Escape, Home, End)
- Cross-browser compatible (Chrome, Firefox, Safari, Edge, iOS)
Installation
npm install @alsocoder/apna-selectPeer dependencies:
npm install react react-domSetup
Import the stylesheet once in your app entry:
import { ApnaSelect } from "@alsocoder/apna-select"
import "@alsocoder/apna-select/styles.css"Basic Usage
Static single select
import { ApnaSelect } from "@alsocoder/apna-select"
import "@alsocoder/apna-select/styles.css"
const options = [
{ value: "active", label: "Active" },
{ value: "inactive", label: "Inactive" },
]
export function StatusSelect() {
const [status, setStatus] = useState("")
return (
<ApnaSelect
options={options}
value={status}
onValueChange={setStatus}
placeholder="Select status"
/>
)
}Multi select
<ApnaSelect
multiple
options={options}
value={selected}
onValueChange={setSelected}
placeholder="Select skills"
/>Theming with CSS Variables
Override colors globally without touching component code:
:root {
--apna-select-trigger-border: #3b82f6;
--apna-select-trigger-bg: #eff6ff;
--apna-select-option-hover: #dbeafe;
--apna-select-primary: #2563eb;
}Available CSS variables
| Variable | Default fallback | Description |
|----------|------------------|-------------|
| --apna-select-trigger-bg | --input | Trigger background |
| --apna-select-trigger-border | --border | Trigger border |
| --apna-select-trigger-text | --foreground | Selected text color |
| --apna-select-trigger-placeholder | --muted-foreground | Placeholder color |
| --apna-select-content-bg | --popover | Dropdown background |
| --apna-select-content-text | --popover-foreground | Dropdown text |
| --apna-select-content-border | --border | Dropdown border |
| --apna-select-option-hover | --muted | Option hover background |
| --apna-select-option-selected | --muted | Selected option background |
| --apna-select-option-highlighted | primary tint | Keyboard/mouse highlight |
| --apna-select-muted | --muted-foreground | Muted text/icons |
| --apna-select-ring | --ring | Focus ring color |
| --apna-select-primary | --primary | Checkbox/check color |
| --apna-select-destructive | --destructive | Error text color |
| --apna-select-radius | 0.5rem | Border radius |
| --apna-select-font-size | 0.875rem | Font size |
Customization
Level 1: Shortcut props
<ApnaSelect
options={options}
className="my-trigger"
contentClassName="my-dropdown"
/>Level 2: Granular classNames
<ApnaSelect
options={options}
classNames={{
trigger: "rounded-xl",
option: "font-bold",
}}
/>Level 3: CSS variables (see above)
Available classNames slots
| Slot | Default class | Description |
|------|---------------|-------------|
| field | apna-select-field | Trigger wrapper |
| trigger | apna-select-trigger | Main trigger |
| triggerLabel | apna-select-trigger-label | Selected label text |
| triggerBadges | apna-select-badges | Badge container (multi) |
| badge | apna-select-badge | Single badge |
| badgeLabel | apna-select-badge-label | Badge text |
| badgeRemove | apna-select-badge-remove | Badge remove button |
| clear | apna-select-clear | Clear button |
| chevron | apna-select-chevron | Chevron icon |
| content | apna-select-content | Popover panel |
| groupLabel | apna-select-group-label | Option group heading |
| searchContainer | apna-select-search | Search bar wrapper |
| searchInput | apna-select-search-input | Search input |
| searchIcon | apna-select-search-icon | Search icon |
| list | apna-select-list | Options scroll container |
| option | apna-select-option | Each option button |
| optionSelected | apna-select-option--selected | Selected option state |
| optionHighlighted | apna-select-option--highlighted | Keyboard/mouse highlight |
| optionLabel | apna-select-option-label | Option label text |
| optionCheck | apna-select-option-check | Check icon (single select) |
| empty | apna-select-empty | Empty state message |
| checkbox | apna-select-checkbox | Multi-select checkbox |
| loading | apna-select-loading | Loading state |
| error | apna-select-error | Error state |
| retryButton | apna-select-retry | Retry button |
| spinner | apna-select-spinner | Loading spinner |
Option groups
Add a group field on options:
const options = [
{ value: "in", label: "India", group: "Asia" },
{ value: "jp", label: "Japan", group: "Asia" },
{ value: "us", label: "United States", group: "Americas" },
]
<ApnaSelect options={options} placeholder="Select country" />Clearable + badge multi-select
<ApnaSelect clearable options={options} value={value} onValueChange={setValue} />
<ApnaSelect
multiple
multipleDisplay="badges"
clearable
options={options}
value={selected}
onValueChange={setSelected}
/>When theming with CSS variables, apply the same theme class to classNames.content as well — the dropdown renders in a portal.
Async Usage
Fetch once (load on open)
<ApnaSelect
url="/api/cities"
mapResponse={(data) =>
data.map((city) => ({ value: city.id, label: city.name }))
}
placeholder="Select city"
/>Server-side search
<ApnaSelect
url="/api/users"
searchMode="server"
searchParam="q"
debounceMs={300}
minSearchLength={2}
mapResponse={(data) => data.users}
placeholder="Search users"
/>Custom fetcher (axios, auth, etc.)
<ApnaSelect
searchMode="server"
debounceMs={400}
loadOptions={async (search, { signal }) => {
const res = await fetch(`/api/products?q=${search}`, { signal })
const data = await res.json()
return data.items.map((item) => ({
value: item.slug,
label: item.title,
}))
}}
/>Standalone hook
import { ApnaSelect, useApnaSelectOptions } from "@alsocoder/apna-select"
const { options, loading, error, retry } = useApnaSelectOptions({
url: "/api/states",
mapResponse: (data) => data.states,
})
return (
<ApnaSelect
options={options}
loading={loading}
error={error}
onRetry={retry}
/>
)Props
Core
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| options | ApnaSelectOption[] | — | Static options (required in static mode) |
| multiple | boolean | false | Enable multi select |
| value | string \| string[] | — | Controlled value |
| defaultValue | string \| string[] | — | Uncontrolled default |
| onValueChange | function | — | Change handler |
| placeholder | string | "Select option" | Placeholder text |
| searchPlaceholder | string | "Search..." | Search input placeholder |
| emptyText | string | "No results found." | Empty state text |
| minSearchText | string | auto | Prompt before server search |
| clearable | boolean | false | Show clear button when selected |
| clearLabel | string | "Clear selection" | Clear button aria-label |
| multipleDisplay | "text" \| "badges" | "text" | Multi-select display mode |
| resolveLabel | function | — | Resolve label for controlled values |
| searchThreshold | number | 8 | Min options before search shows |
| disabled | boolean | false | Disable the select |
| name | string | — | Hidden input name for forms |
| id | string | — | HTML id attribute |
| className | string | — | Trigger class shortcut |
| contentClassName | string | — | Content class shortcut |
| classNames | object | — | Granular class overrides |
| filterFn | function | label match | Client-side filter function |
| icons | object | inline SVGs | Custom chevron/search/check icons |
Async
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| url | string | — | API endpoint |
| method | "GET" \| "POST" | "GET" | HTTP method |
| headers | object | — | Request headers |
| body | object | — | POST request body |
| searchMode | "client" \| "server" | "client" | Search behavior |
| searchParam | string | "search" | Query param for server search |
| debounceMs | number | 300 | Debounce for server search |
| minSearchLength | number | 0 | Min chars before API call |
| mapResponse | function | auto-detect | Transform API response |
| loadOptions | function | — | Custom async loader |
| fetchOnMount | boolean | true | Fetch when dropdown opens |
| cacheKey | string | url-based | Cache key |
| loading | boolean | auto | Manual loading state |
| error | string \| Error | auto | Manual error state |
| onRetry | function | auto | Retry handler |
| loadingText | string | "Loading..." | Loading message |
| errorText | string | — | Custom error message |
Native Form Integration
<form onSubmit={handleSubmit}>
<ApnaSelect
name="service"
options={options}
value={service}
onValueChange={setService}
/>
<button type="submit">Submit</button>
</form>react-hook-form Example
import { Controller, useForm } from "react-hook-form"
import { ApnaSelect } from "@alsocoder/apna-select"
function MyForm() {
const { control, handleSubmit } = useForm()
return (
<form onSubmit={handleSubmit(console.log)}>
<Controller
name="status"
control={control}
render={({ field }) => (
<ApnaSelect
options={options}
value={field.value}
onValueChange={field.onChange}
/>
)}
/>
</form>
)
}Keyboard Navigation
| Key | Action |
|-----|--------|
| ArrowDown / ArrowUp | Move highlight / open dropdown |
| Enter / Space | Select highlighted option / open dropdown |
| Home / End | Jump to first / last option |
| Escape | Close dropdown |
| Tab | Close dropdown and move focus |
Works on trigger button and search input. Mouse hover syncs with keyboard highlight.
Browser Compatibility
Tested and supported on modern browsers:
| Browser | Minimum Version | |---------|-----------------| | Chrome | 90+ | | Firefox | 90+ | | Safari | 14+ | | Edge | 90+ | | iOS Safari | 14+ | | Samsung Internet | 15+ |
CSS includes fallbacks for older engines:
- Solid color fallbacks before
color-mix() -webkit-prefixes for flexbox and animationsprefers-reduced-motionsupport (disables animations)-webkit-overflow-scrolling: touchfor iOS scroll
Requires React 18+. Internet Explorer is not supported.
Exports
import {
ApnaSelect,
useApnaSelectOptions,
defaultClassNames,
defaultMapResponse,
clearOptionsCache,
} from "@alsocoder/apna-select"Changelog
v0.2.3
onBlurprop for react-hook-form integrationfieldError/fieldErrorTextfor field-level validation (separate from async fetch errors)label,required, trigger error border, andaria-invalid
v0.2.2
DismissableLayerBranchon portaled popover — works inside Radix Dialog / ApnaModalpointer-events: autoon popover for Dialog body scroll-lock compatibility@radix-ui/react-dismissable-layeradded as explicit dependency
v0.2.1
- Popover z-index uses
--apna-overlay-nested-z-popoverfor ApnaModal compatibility
v0.2.0
clearableprop with clear buttonmultipleDisplay="badges"for chip-style multi-select- Option groups via
groupfield on options resolveLabelfor controlled server-search valuesminSearchTextfor async server search prompt state- Selected label cache for async server search
- Keyboard, scroll, and async UX fixes
v0.1.0
- Initial release
- Single/multi select with search
- Async API loading (
url,loadOptions,useApnaSelectOptions) - CSS variables for theming
- Customizable via
className,classNames, and CSS variables - Keyboard navigation and accessibility improvements
- Cross-browser CSS with fallbacks and reduced-motion support
Development
npm install
npm run build
cd playground && npm install && npm run devRoadmap
- Cascading/dependent selects (composition pattern)
License
MIT
