react-next-select
v0.1.2
Published
Accessible, SSR-safe React Select for Next.js — single/multi, async, customizable
Maintainers
Readme
react-next-select
Accessible, SSR-safe React Select component for Next.js — single/multi, async, fully customizable.
Built in JavaScript (ES6+) with no external runtime dependencies, ships ESM + CJS, and works out of the box with the Next.js App Router and Pages Router.
🚀 Live Demo
👉 react-next-select.netlify.app
Try every prop interactively, switch between light/dark themes, and customize the accent color live — the demo includes a Theme Studio, a Props Playground, and copy-ready code snippets for every variant.
Features
- Single select and multi-select
- Searchable dropdown (inline or a separate in-menu search input)
- Async options loading with race-condition-safe requests
- Custom option / control / menu / indicator rendering
- Full keyboard navigation (Arrow / Home / End / Enter / Esc / Tab)
- Clearable input
- Disabled and loading states
- Controlled and uncontrolled support (
value,inputValue,menuIsOpen) - Hidden
<input name>for native form submission - SSR-safe behavior for Next.js (no
window/documentaccess on render) - Zero runtime dependencies; React 18 and React 19 both supported
Requirements
- React
^18.0.0or^19.0.0 - React DOM
^18.0.0or^19.0.0 - Node
>=16(for build tooling only)
Installation
npm install react-next-select
# or
yarn add react-next-select
# or
pnpm add react-next-selectImport the default stylesheet once in your app:
import 'react-next-select/style.css'Next.js Usage
App Router (app/page.js)
'use client'
import { useState } from 'react'
import { Select } from 'react-next-select'
import 'react-next-select/style.css'
const options = [
{ value: 'next', label: 'Next.js' },
{ value: 'vite', label: 'Vite' },
{ value: 'rollup', label: 'Rollup' },
]
export default function Page() {
const [value, setValue] = useState(null)
return (
<Select
options={options}
value={value}
onChange={setValue}
isClearable
placeholder="Pick one..."
/>
)
}The
'use client'directive is required becauseSelectis a client component (it manages local state and DOM focus).
Pages Router (pages/index.js)
import { useState } from 'react'
import { Select } from 'react-next-select'
import 'react-next-select/style.css'
const options = [
{ value: 'next', label: 'Next.js' },
{ value: 'vite', label: 'Vite' },
]
export default function Home() {
const [value, setValue] = useState(null)
return <Select options={options} value={value} onChange={setValue} />
}Multi-select
<Select
isMulti
options={options}
value={value}
onChange={setValue}
isClearable
/>Async options
<Select
loadOptions={async (input) => {
const res = await fetch(`/api/search?q=${encodeURIComponent(input)}`)
return res.json()
}}
defaultOptions
placeholder="Search users..."
/>API
Exports
import {
Select, // main component
defaultComponents, // default subcomponent map (Control, Option, Menu, ...)
mergeStyles, // helper to merge styles from the `styles` prop
SelectContext, // React context exposing internal state
useSelectContext, // hook to read SelectContext from custom components
} from 'react-next-select'Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| options | Option[] | [] | List of selectable options. |
| value | Option \| Option[] | — | Controlled selected value. |
| defaultValue | Option \| Option[] | null / [] | Uncontrolled initial value. |
| onChange | (value, meta) => void | — | Selection change callback. meta.action is one of select-option, remove-value, clear. |
| isMulti | boolean | false | Allow multiple values. |
| isSearchable | boolean | true | Enable search input. |
| isClearable | boolean | false | Show clear icon when a value is selected. |
| isDisabled | boolean | false | Disable the control. |
| isLoading | boolean | false | Show loading message in the menu. |
| loadOptions | (inputValue) => Promise<Option[]> | — | Async loader. Enables async mode. |
| defaultOptions | boolean \| Option[] | false | Preload options for async mode. |
| filterOption | (option, input) => boolean | — | Custom option filter for sync mode. |
| getOptionValue | (option) => string | o => o.value | Extract value from an option. |
| getOptionLabel | (option) => string | o => o.label | Extract label from an option. |
| placeholder | string | 'Select...' | Placeholder text. |
| noOptionsMessage | ({ inputValue }) => string | () => 'No options' | Message when filter returns nothing. |
| loadingMessage | ({ inputValue }) => string | () => 'Loading...' | Message while async loading. |
| inputValue | string | — | Controlled search input. |
| defaultInputValue | string | '' | Uncontrolled initial input value. |
| onInputChange | (value, meta) => void | — | Search input callback. |
| menuIsOpen | boolean | — | Controlled menu open state. |
| onMenuOpen / onMenuClose | () => void | — | Menu lifecycle callbacks. |
| closeMenuOnSelect | boolean | !isMulti | Close menu after selecting an option. |
| blurInputOnSelect | boolean | true | Blur the input after selecting. |
| menuPlacement | 'bottom' \| 'top' | 'bottom' | Menu placement relative to control. |
| showMenuSearchInput | boolean | false | Render a separate search input inside the menu. |
| menuSearchPlaceholder | string | 'Search...' | Placeholder for the in-menu search input. |
| menuSearchInputProps | object | {} | Extra props for the in-menu search <input>. |
| dropdownIcon | ReactNode \| ({ isOpen }) => ReactNode | — | Replace just the chevron icon — accepts a string, element, or render function. The wrapper handles the 180° open/close rotation automatically. |
| components | object | — | Override internal subcomponents (Control, Option, Menu, MenuList, Input, DropdownIndicator, ClearIndicator, SingleValue, MultiValue, LoadingMessage, NoOptionsMessage). |
| styles | object | {} | Style override map (see Styling). |
| formatOptionLabel | (option, { context }) => ReactNode | — | Custom label renderer. context is 'menu' or 'value'. |
| className | string | — | Extra class on the wrapper. |
| classNamePrefix | string | 'rns' | Prefix for inner element classNames. |
| style | object | — | Inline style on the wrapper. |
| name | string | — | Render a hidden <input> with the serialized value for form submission. |
| id | string | auto | Base id; used for the listbox and option ids. |
| aria-label / aria-labelledby | string | — | Accessibility labels. |
| tabIndex | number | 0 | Tab index on the control. |
Option is any object — { value, label } by default — or anything else if you provide getOptionValue / getOptionLabel.
Styling
Two styling strategies are supported and can be combined.
1) CSS override (recommended)
import { Select } from 'react-next-select'
import 'react-next-select/style.css'
import './my-select-theme.css'
export default function Demo() {
return (
<Select
options={[
{ value: 'next', label: 'Next.js' },
{ value: 'vite', label: 'Vite' },
]}
className="mySelect"
classNamePrefix="mySelect"
isClearable
showMenuSearchInput
menuSearchPlaceholder="Search options..."
/>
)
}my-select-theme.css
.mySelect__wrapper .rns__control {
border: 1px solid #7c3aed;
border-radius: 10px;
background: #faf5ff;
}
.mySelect__wrapper .rns__control:focus-within {
border-color: #6d28d9;
box-shadow: 0 0 0 2px rgba(109, 40, 217, 0.25);
}
.mySelect__wrapper .rns__multi-value {
background: #ede9fe;
color: #4c1d95;
}
.mySelect__wrapper .rns__menu-inner {
border: 1px solid #ddd6fe;
}
.mySelect__wrapper .rns__option:hover {
background: #f5f3ff;
}
.mySelect__wrapper .rns__option[aria-selected='true'] {
background: #ede9fe;
color: #5b21b6;
}
.mySelect__wrapper .rns__menu-search-input-wrap {
border-color: #c4b5fd;
}
.mySelect__wrapper .rns__menu-search-input {
color: #1f2937;
}
.mySelect__wrapper .rns__menu-search-input::placeholder {
color: #8b5cf6;
}
.mySelect__wrapper .rns__menu-search-icon {
color: #7c3aed;
}
.mySelect__wrapper .rns__menu-search-clear {
color: #7c3aed;
}2) styles prop
<Select
options={options}
styles={{
control: (base, state) => ({
...base,
borderColor: state.isFocused ? '#6d28d9' : '#c4b5fd',
background: '#faf5ff',
boxShadow: state.isFocused ? '0 0 0 2px rgba(109,40,217,0.25)' : 'none',
borderRadius: 10,
}),
menu: (base) => ({
...base,
border: '1px solid #ddd6fe',
borderRadius: 10,
}),
option: (base, state) => ({
...base,
background: state.isSelected ? '#ede9fe' : state.isFocused ? '#f5f3ff' : '#fff',
color: state.isSelected ? '#5b21b6' : '#111827',
}),
multiValue: (base) => ({
...base,
background: '#ede9fe',
color: '#4c1d95',
}),
}}
/>Custom Dropdown Icon
Swap the default chevron without writing a full subcomponent — pass anything renderable to dropdownIcon. The wrapper rotates 180° on open/close, so any icon you provide animates automatically.
// 1) String, emoji, or any character
<Select options={options} dropdownIcon="⌄" />
// 2) Any React node — your own SVG, an icon library, an image, etc.
import { ChevronDown } from 'lucide-react'
<Select options={options} dropdownIcon={<ChevronDown size={14} />} />
// 3) Render function — receives { isOpen } if you want different icons per state.
// Tip: the wrapper still rotates 180°, so for stateful swaps either
// return rotation-safe artwork or override `components.DropdownIndicator`.
<Select
options={options}
dropdownIcon={({ isOpen }) => (isOpen ? '−' : '+')}
/>Need full control (different markup, no rotation, custom click handling)? Override the whole component instead:
<Select
components={{
DropdownIndicator: ({ innerProps }) => (
<button {...innerProps} className="my-caret" aria-hidden tabIndex={-1}>
▼
</button>
),
}}
/>Custom Components
Override any subcomponent through the components prop.
function MyOption({ innerProps, data, isFocused }) {
return (
<div
{...innerProps}
style={{
padding: '10px 12px',
background: isFocused ? '#eff6ff' : '#fff',
}}
>
<strong>{data.label}</strong>
</div>
)
}
<Select
options={options}
components={{
Option: MyOption,
}}
/>Overridable component keys: Control, ValueContainer, IndicatorsContainer, DropdownIndicator, ClearIndicator, Input, Menu, MenuList, Option, LoadingMessage, NoOptionsMessage, SingleValue, MultiValue.
Accessibility
- Control exposes
role="combobox"witharia-expanded,aria-controls,aria-haspopup="listbox". - Each option exposes
role="option"witharia-selected. - The active option is tracked through
aria-activedescendant. - Pass
aria-labeloraria-labelledbyto label the control when no visible label is associated.
Build Output
- Bundler: Vite (library mode)
- Formats:
- ESM:
dist/index.js - CommonJS:
dist/index.cjs
- ESM:
- Stylesheet:
dist/style.css - Sourcemaps included
- Peer dependencies:
react,react-dom(kept external)
Build locally:
npm install
npm run buildWatch mode while developing:
npm run devPublishing to npm
The prepublishOnly script runs npm run build automatically, so a fresh dist/ is produced before each publish.
# 1. Bump the version
npm version patch # or: minor / major
# 2. Login (first time only)
npm login
# 3. Publish
npm publish --access publicLocal Example App
A Next.js example app is included at examples/. You can also see it live at react-next-select.netlify.app.
cd examples
npm install
npm run devLicense
MIT © 2026 Yogesh Gabani
Built by Yogesh Gabani.
