f-ui
Components

Select

Unified single select with static, searchable, async, and preload modes built on Popover and Command.

Pick one value from a list. A button trigger opens a popover; the search box appears only when the list needs it — never for small static enums. One component covers four modes inferred from props: static (options), searchable static (more than 8 options or searchable), async (onSearch), and preload (onSearch + preload). Use Select for a label and helper text, SelectControl for the field only (forms use it through Formily's FormItem), or useSelect for fully custom layouts.

Built-in labels use useSelectI18n() under a tree with FuiI18nProvider (registry item fui-i18n).

Installing

pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/select.json
npx shadcn@latest add https://ui.isaacfei.com/r/select.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/r/select.json
bun x shadcn@latest add https://ui.isaacfei.com/r/select.json

Or with a namespace: npx shadcn@latest add @f-ui/select.

The CLI installs cmdk and lucide-react, and pulls command, label, and popover from the default shadcn registry.

Usage

import { Select } from '@/components/f-ui/select/select';
import type { Option } from '@/components/f-ui/select/select-types';

const options: Option[] = [
  { value: 'draft', label: 'Draft' },
  { value: 'published', label: 'Published' },
];

<Select options={options} value={value} onValueChange={setValue} />

Clearing emits null (not an empty string). In async mode, seed the initial selected label with defaultOptions={[{ value, label }]} — the component never calls onSearch with a selected id.

Examples

Static

Small enum — the popover lists options with no search box.

Small static list — no search box

"use client";

import { useState } from "react";

import { Select } from "@/components/f-ui/select/select";
import type { Option } from "@/components/f-ui/select/select-types";

const STATUSES: Option[] = [
  { value: "draft", label: "Draft" },
  { value: "review", label: "In review" },
  { value: "published", label: "Published" },
  { value: "archived", label: "Archived", disable: true },
];

export function SelectDemo() {
  const [value, setValue] = useState<string | null>("review");

  return (
    <div className="max-w-xs">
      <Select
        label="Status"
        description="Small static list — no search box"
        options={STATUSES}
        value={value}
        onValueChange={setValue}
      />
    </div>
  );
}

Searchable Static

Past SELECT_SEARCH_THRESHOLD (8) options, the search box appears automatically; cmdk filters locally. Force it on or off with searchable.

More than 8 options — search appears automatically

"use client";

import { useState } from "react";

import { Select } from "@/components/f-ui/select/select";
import type { Option } from "@/components/f-ui/select/select-types";

const TIMEZONES: Option[] = [
  "Africa/Cairo", "America/Chicago", "America/New_York", "America/Sao_Paulo",
  "Asia/Dubai", "Asia/Shanghai", "Asia/Singapore", "Asia/Tokyo",
  "Australia/Sydney", "Europe/Berlin", "Europe/London", "Europe/Paris",
  "Pacific/Auckland", "UTC",
].map((tz) => ({ value: tz, label: tz.replace("_", " ") }));

export function SelectSearchableDemo() {
  const [value, setValue] = useState<string | null>(null);

  return (
    <div className="max-w-xs">
      <Select
        label="Timezone"
        description="More than 8 options — search appears automatically"
        options={TIMEZONES}
        value={value}
        onValueChange={setValue}
        placeholder="Pick a timezone"
      />
    </div>
  );
}

onSearch runs per debounced keystroke (delay, default 300 ms); triggerSearchOnFocus fetches an initial list when the popover opens. Rejections render an inline error row.

Each keystroke runs a debounced onSearch

"use client";

import { useCallback, useState } from "react";

import { Select } from "@/components/f-ui/select/select";
import type { Option } from "@/components/f-ui/select/select-types";

/** Static pool "fetched" asynchronously — replace with your API in real apps. */
const USERS: Option[] = [
  { value: "u1", label: "Ada Lovelace" },
  { value: "u2", label: "Alan Turing" },
  { value: "u3", label: "Grace Hopper" },
  { value: "u4", label: "Katherine Johnson" },
  { value: "u5", label: "Margaret Hamilton" },
  { value: "u6", label: "Donald Knuth" },
  { value: "u7", label: "Barbara Liskov" },
  { value: "u8", label: "Edsger Dijkstra" },
];

function delay(ms: number) {
  return new Promise<void>((resolve) => {
    setTimeout(resolve, ms);
  });
}

export function SelectAsyncDemo() {
  const [value, setValue] = useState<string | null>(null);

  const onSearch = useCallback(async (query: string) => {
    await delay(400);
    const q = query.trim().toLowerCase();
    if (!q) return USERS.slice(0, 5);
    return USERS.filter((u) => u.label.toLowerCase().includes(q));
  }, []);

  return (
    <div className="max-w-xs">
      <Select
        label="Assignee"
        description="Each keystroke runs a debounced onSearch"
        onSearch={onSearch}
        triggerSearchOnFocus
        value={value}
        onValueChange={setValue}
        placeholder="Search users"
      />
    </div>
  );
}

Preload

With preload, the full list is fetched once on first open, then filterFn filters locally — no further requests.

One fetch on first open, then local filtering

"use client";

import { useCallback, useState } from "react";

import { Select } from "@/components/f-ui/select/select";
import type { Option } from "@/components/f-ui/select/select-types";

const COUNTRIES: Option[] = [
  { value: "AR", label: "Argentina" }, { value: "AU", label: "Australia" },
  { value: "BR", label: "Brazil" }, { value: "CA", label: "Canada" },
  { value: "CN", label: "China" }, { value: "DE", label: "Germany" },
  { value: "FR", label: "France" }, { value: "GB", label: "United Kingdom" },
  { value: "IN", label: "India" }, { value: "JP", label: "Japan" },
  { value: "KR", label: "South Korea" }, { value: "MX", label: "Mexico" },
  { value: "SG", label: "Singapore" }, { value: "US", label: "United States" },
];

function delay(ms: number) {
  return new Promise<void>((resolve) => {
    setTimeout(resolve, ms);
  });
}

export function SelectPreloadDemo() {
  const [value, setValue] = useState<string | null>(null);

  const fetchAll = useCallback(async () => {
    await delay(600);
    return COUNTRIES;
  }, []);

  return (
    <div className="max-w-xs">
      <Select
        label="Country"
        description="One fetch on first open, then local filtering"
        onSearch={fetchAll}
        preload
        filterFn={(option, query) =>
          option.label.toLowerCase().includes(query.toLowerCase())
        }
        value={value}
        onValueChange={setValue}
        placeholder="Pick a country"
      />
    </div>
  );
}

Headless Usage

Call useSelect(fieldProps) and compose Popover, your own trigger, and SelectDropdown — the same building blocks SelectControl uses.

"use client";

import { useState } from "react";

import { Popover, PopoverTrigger } from "@/components/ui/popover";
import { useSelect } from "@/components/f-ui/select/use-select";
import { SelectDropdown } from "@/components/f-ui/select/select-parts/select-dropdown";
import type { Option } from "@/components/f-ui/select/select-types";

const PRIORITIES: Option[] = [
  { value: "p0", label: "Urgent" },
  { value: "p1", label: "High" },
  { value: "p2", label: "Normal" },
  { value: "p3", label: "Low" },
];

export function SelectHeadlessDemo() {
  const [value, setValue] = useState<string | null>("p2");
  const s = useSelect({ value, onValueChange: setValue, options: PRIORITIES });
  const selected = s.selectedOption;

  return (
    <Popover modal={false} open={s.open} onOpenChange={s.onOpenChange}>
      <PopoverTrigger asChild>
        <button
          type="button"
          role="combobox"
          aria-expanded={s.open}
          aria-haspopup="listbox"
          aria-label="Priority"
          ref={s.triggerRef}
          className="inline-flex h-8 items-center gap-2 rounded-full border border-input px-3 text-sm"
        >
          <span
            aria-hidden
            className="size-2 rounded-full bg-muted-foreground data-[p=p0]:bg-destructive"
            data-p={value ?? undefined}
          />
          {selected?.label ?? s.placeholder}
        </button>
      </PopoverTrigger>
      <SelectDropdown
        searchable={false}
        searchTerm={s.searchTerm}
        onSearchTermChange={s.setSearchTerm}
        searchPlaceholder={s.searchPlaceholder}
        shouldFilter={s.shouldFilter}
        options={s.displayedOptions}
        selectedValue={value}
        onSelect={s.onSelect}
        isLoading={s.isLoading}
        loadingLabel={s.t("select.loading")}
        errorMessage={s.errorMessage}
        noResultsLabel={s.t("select.noResults")}
      />
    </Popover>
  );
}

Composition

Select (optional label + description shell)
└── SelectControl
    ├── Popover
    ├── PopoverTrigger → SelectTrigger (button + clear + chevron)
    └── SelectDropdown → PopoverContent
        └── Command
            ├── CommandInput (searchable modes)
            └── CommandList
                ├── CommandEmpty | SelectCommandEmpty (loading / error / empty)
                └── CommandGroup → CommandItem (check on selected)

API Reference

Props

PropTypeDefault
valuestring | null
onValueChange(value: string | null) => void
optionsOption[]
defaultOptionsOption[]— (seeds the label cache)
onSearch(query: string) => Promise<Option[]>
preloadbooleanfalse
filterFn(option: Option, query: string) => booleanlabel/value includes
searchablebooleaninferred (see modes)
clearablebooleantrue
delaynumber300
triggerSearchOnFocusbooleanfalse
placeholderstringselect.placeholder
renderOption / renderValue(option: Option) => ReactNodeoption.label
loadingIndicator / emptyIndicatorReactNodebuilt-in states
surface'default' | 'compact''default'
label / descriptionReactNode— (Select shell only)
locale / ti18n overridesprovider context

Slots

SlotApplied to
rootWrapper around label + control + description
labelLabel
control / triggerTrigger button
contentPopoverContent
listCommandList
itemEach CommandItem

Hook

FunctionuseSelect(props: UseSelectOptions)
OptionsUseSelectOptions — same shape as SelectFieldProps.
ReturnUseSelectReturn: mode, open / onOpenChange, triggerRef, searchTerm, displayedOptions, selectedOption, isLoading, errorMessage, onSelect, onClear, t, etc.

On this page