@mshafiqyajid/react-select
Headless select hook and styled dropdown for React. Single and multi-select, searchable, clearable, chips for multi-value, controllable placement (auto/top/bottom) with flip and offset, portal positioning, full keyboard navigation.
Playground #
Props
TSX
import { SelectStyled } from "@mshafiqyajid/react-select/styled";
import "@mshafiqyajid/react-select/styles.css";
<SelectStyled
items={items}
value={value}
onChange={setValue}
/>Grouped items
Grouped single select
Grouped multi-select with renderItem
Render props
Custom renderTrigger
Async loadOptions
Async loadOptions (mock 600ms delay)
Install #
npm install @mshafiqyajid/react-select Quick start #
import { SelectStyled } from "@mshafiqyajid/react-select/styled";
import "@mshafiqyajid/react-select/styles.css";
const items = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "svelte", label: "Svelte" },
];
const [value, setValue] = useState("");
<SelectStyled
items={items}
value={value}
onChange={setValue}
placeholder="Pick a framework"
/> Multi-select #
const [values, setValues] = useState<string[]>([]);
<SelectStyled
items={items}
value={values}
onChange={setValues}
multiple
clearable
/> Searchable #
<SelectStyled items={items} value={value} onChange={setValue} searchable /> Async loadOptions #
<SelectStyled
items={[]} // seeds + provides labels for selected values
value={value}
onChange={setValue}
loadOptions={async (query) => {
const res = await fetch(`/api/users?q=${encodeURIComponent(query)}`);
return res.json(); // SelectItem[]
}}
debounceMs={300} // default
loadingText="Searchingβ¦"
emptyText="No matches"
errorText="Couldn't load β try again"
/> Debounced on searchValue; stale requests cancelled via AbortController. Hook exposes isLoading + loadError; listbox lands aria-busy="true" while in flight.
Headless #
import { useSelect } from "@mshafiqyajid/react-select";
const items = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
];
const [value, setValue] = useState("react");
const { triggerProps, listboxProps, getItemProps, isOpen } = useSelect({
items,
value,
onChange: (v) => setValue(v as string),
});
return (
<>
<button {...triggerProps}>{value || "Selectβ¦"}</button>
{isOpen && (
<ul {...listboxProps} className="listbox">
{items.map((item) => (
<li key={item.value} {...getItemProps(item)}>{item.label}</li>
))}
</ul>
)}
</>
); API #
| Prop | Type | Default | Description |
|---|---|---|---|
| items | SelectItem[] | β | Array of { value, label, disabled? } |
| value | string | string[] | β | Selected value(s) |
| onChange | (v: string | string[]) => void | β | Called on selection change |
| placeholder | string | "Selectβ¦" | Placeholder text |
| multiple | boolean | false | Allow multiple selections |
| searchable | boolean | false | Show search input in dropdown |
| clearable | boolean | false | Show clear button |
| size | "sm" | "md" | "lg" | "md" | Size variant |
| tone | "neutral" | "primary" | "success" | "danger" | "neutral" | Color tone |
| disabled | boolean | false | Disable the select |
| placement | "auto" | "top" | "bottom" | "auto" | Listbox side. "auto" picks bottom unless there isn't room. |
| offset | number | 4 | Gap between trigger and listbox |
| collisionPadding | number | 8 | Viewport edge margin used by flip |
| flip | boolean | true | Auto-flip when there isn't room on the chosen side |
| strategy | "absolute" | "fixed" | "absolute" | Positioning strategy |
data-placement="top" | "bottom" lands on the dropdown so you can flip arrow direction or radius. Listbox width still tracks the trigger.