Components
Dual Picker
A two-panel picker for single or multiple selection — option rows and selected rows are render-prop driven, so any content format works.
Installation
$npx litefy@latest add dual-picker
$pnpm dlx litefy@latest add dual-picker
$yarn dlx litefy@latest add dual-picker
$bun --bun litefy@latest add dual-picker
Usage
DualPicker is the two-panel picker (source + selected). Click an option to select it — in "multiple" mode it toggles, in "single" mode it replaces the previous pick. Click a selected entry to remove it. The search input filters the source panel.
Custom content (e.g. products with images)
Option rows and selected rows are render-prop driven — pass renderOption / renderSelected to use any format, such as product cards with images:
Options
Mechanical keyboard$129
Wireless mouse$49
4K monitor$349
USB-C hub$39
Webcam$89
Selected0
Nothing selected
"use client";import { DualPicker } from "@/ui";type Product = { name: string; emoji: string; price: number };const products: Product[] = [ { name: "Mechanical keyboard", emoji: "⌨️", price: 129 }, { name: "Wireless mouse", emoji: "🖱️", price: 49 }, { name: "4K monitor", emoji: "🖥️", price: 349 }, { name: "USB-C hub", emoji: "🔌", price: 39 }, { name: "Webcam", emoji: "📷", price: 89 },];export default function Demo() { return ( <DualPicker<Product> className="w-full max-w-xl" mode="multiple" options={products.map((p) => ({ value: p.name, option: p }))} getLabel={(p) => `${p.emoji} ${p.name}`} renderOption={(product, { selected }) => ( <span className="flex w-full items-center justify-between gap-2"> <span className="flex min-w-0 items-center gap-2"> <span aria-hidden>{product.emoji}</span> <span className="truncate">{product.name}</span> </span> <span className="shrink-0 text-xs text-muted-foreground"> ${product.price} {selected && " · selected"} </span> </span> )} renderSelected={(product, { onRemove }) => ( <span className="flex w-full items-center justify-between gap-2"> <span className="truncate"> {product.emoji} {product.name} </span> <button type="button" className="shrink-0 text-xs text-muted-foreground underline hover:text-danger" onClick={(e) => { e.stopPropagation(); onRemove(); }} > remove </button> </span> )} onValueChange={(values) => console.log("selected:", values)} /> );}Single mode
Members
Ada Lovelace
Grace Hopper
Linus Pauling
Marie Curie
Owner0
Nothing selected
Owner: —
"use client";import { useState } from "react";import { DualPicker } from "@/ui";interface Member { name: string; role: string;}const members: Member[] = [ { name: "Ada Lovelace", role: "Engineering" }, { name: "Grace Hopper", role: "Engineering" }, { name: "Linus Pauling", role: "Research" }, { name: "Marie Curie", role: "Research" },];export default function Demo() { const [owner, setOwner] = useState<string[]>([]); return ( <div className="w-full max-w-xl"> <DualPicker<Member> mode="single" options={members.map((m) => ({ value: m.name, option: m }))} getLabel={(m) => m.name} searchPlaceholder="Search members..." sourceTitle="Members" targetTitle="Owner" value={owner} onValueChange={setOwner} /> <p className="mt-2 text-sm text-muted-foreground">Owner: {owner[0] ?? "—"}</p> </div> );}API Reference
DualPicker<T>
| Prop | Type | Default | Description |
|---|---|---|---|
options | { value: string; option: T }[] | - | Source options; value is the selection key, option is the raw business item. Required |
mode | "single" | "multiple" | "multiple" | "single" keeps at most one selection |
value | string[] | - | Controlled selection (single mode holds 0 or 1 entries) |
defaultValue | string[] | [] | Initial selection (uncontrolled) |
onValueChange | (values: string[]) => void | - | Fired when the selection changes |
renderOption | (option: T, { selected }) => React.ReactNode | label | Custom option row format |
renderSelected | (option: T, { selected, onRemove }) => React.ReactNode | label + X | Custom selected row format |
getLabel | (option: T) => string | String(option) | Label used by the default renderers and the search filter |
searchPlaceholder | string | "Search..." | Search input placeholder |
sourceTitle / targetTitle | React.ReactNode | "Options" / "Selected" | Panel titles |
disabled | boolean | false | Disable both panels |
className | ClassNameValue | - | Custom classes, applied to the root |
classNames | panel / header / body / item / selected | - | Custom classes per part |
styles | { panel?, body? } | - | Inline styles for the panels |
For async option loading (remote search + pagination), keep DualPicker for the selection UI and compose it with useRemotePagination — the search input state and option list are yours to feed in.