@vinyasa/form
v2.0.3
Published
All 25 form controls in the "Form Components" category, delivered in three staged phases:
Readme
@vinyasa/form
All 25 form controls in the "Form Components" category, delivered in three staged phases:
- Phase 1: Icon Button, Toggle Button, Input, Search Input, Password Input, Number Input, Textarea, Checkbox, Radio Group, Switch.
- Phase 2: Slider, Range Slider, OTP Input, Select, Combobox, Autocomplete, Multi Select, Tags Input.
- Phase 3 (this round): Date Picker, Time Picker, Color Picker, File Upload, Dropzone, Rich Text Editor.
Installation
pnpm add @vinyasa/form @vinyasa/layout @vinyasa/icons @vinyasa/button @vinyasa/tokens react react-dom@vinyasa/button's Button is composed directly by Icon Button and Toggle Button rather than reimplemented. Every interactive control wraps an established library rather than hand-rolling keyboard/ARIA/drag behavior — all regular dependencies of this package, not something consumers install themselves:
| Component(s) | Library |
| -------------------------------------------- | ------------------------------------------------------------------- |
| Checkbox, Radio Group, Switch, Toggle Button | @radix-ui/react-checkbox / -radio-group / -switch / -toggle |
| Slider, Range Slider | @radix-ui/react-slider |
| Select | @radix-ui/react-select |
| Combobox, Autocomplete, Multi Select | @radix-ui/react-popover + cmdk |
| OTP Input | input-otp |
| Date Picker | react-day-picker + @radix-ui/react-popover + date-fns |
| Color Picker | react-colorful + @radix-ui/react-popover |
| File Upload, Dropzone | react-dropzone |
| Rich Text Editor | @tiptap/react + @tiptap/starter-kit |
Tags Input has no dropdown, so it needs neither Popover nor cmdk — it's a plain chip-token text field. Time Picker wraps a native <input type="time"> rather than a library — the browser already provides an accessible, localized time-entry UI for free.
This package has no root export — every component is subpath-only (import { Checkbox } from '@vinyasa/form/checkbox', never from '@vinyasa/form'). A root barrel re-exporting all 25 components would let a bundler tree-shake the unused JS down to just the one component you import, but the CSS side-effect imports those other 24 components carry are not eligible for the same tree-shaking — confirmed empirically with both esbuild and Rollup, importing one component from a hypothetical root barrel pulled in roughly 8x more CSS than the same component imported by its own subpath. Removing the root entry entirely (rather than just documenting around it) makes that the only possible outcome, not something that depends on your bundler being clever enough to shake it out.
New tokens this package introduces
None across all three phases. Every control's chrome (border, focus ring, invalid/disabled state, floating-content elevation) reuses existing tokens.
New icons this package needed
Added to @vinyasa/icons across the three phases: CalendarIcon, ClockIcon, PaletteIcon, PaperclipIcon, UploadIcon (Phase 1, used by Phase 3's pickers/uploaders) and BoldIcon, ItalicIcon, ListIcon, ListOrderedIcon, QuoteIcon, RedoIcon, StrikethroughIcon, UndoIcon (Phase 3, for Rich Text Editor's toolbar).
Shared internal chrome
src/field.tsx/src/field.css.ts— label/description/error/required/focus-ring/invalid/disabled layout shared by every text-like control. Not a public component — the same "shared internal system" pattern@vinyasa/feedback'sstatus.tsused.src/selectPopover.css.ts— floating-listbox chrome (content/list/item/chip styles) shared by Combobox, Autocomplete, and Multi Select, the three "type to filter a list" patterns built on Popover + cmdk. Select, Date Picker, and Color Picker each have their own<Component>.css.tspopover-content styling instead, since their underlying libraries (@radix-ui/react-select,react-day-picker,react-colorful) have different part structures — Date Picker's content, in particular, deliberately isn't width-constrained to its trigger the way selectPopover's is, since a calendar has its own natural size.
A load-bearing fix, not just a style choice: every popover-based component (Select, Combobox, Autocomplete, Multi Select, Date Picker, Color Picker) deliberately renders its floating content without Portal. VinyasaProvider injects theme CSS custom properties as an inline style on its own wrapper element, not :root — anything rendered via createPortal to document.body (which is what those Portal components do by default) falls outside that subtree, and every var(--vinyasa-*) token resolves to nothing (the exact bug @vinyasa/feedback's Toaster hit and fixed the same way). Radix's own floating-position calculation still works correctly without the portal; only the DOM location changes. Verified in a real browser for each of these six components — the popover content genuinely renders with real, non-transparent styling, not just "doesn't crash."
A cmdk gotcha worth knowing if you touch Combobox/Autocomplete: Command.Input always overwrites any id prop you pass with its own internally-generated one, and gets its accessible name from <Command label="..."> (wired via aria-labelledby to a hidden label cmdk renders itself) — not from FieldChrome's own visible <label htmlFor>. FieldChrome has a disableLabelFor escape hatch for exactly this (omits the now-dangling htmlFor, which would otherwise silently fail to focus anything when a real user clicks the visible label). The same underlying issue — a trigger's accessible name coming from its <label>, not its own visible text content — is why Date Picker's, Color Picker's, and File Upload's own trigger buttons show their current value/state as plain rendered content rather than as part of the button's accessible name; tests and consumers alike should query these by their label text, not by the formatted date/hex/filename shown inside.
react-day-picker is styled entirely through its own classNames prop (keyed by its UI/DayFlag/SelectionState enums) rather than its default stylesheet — the same "headless, theme it yourself" contract already used for Select/Combobox's underlying libraries. DatePicker also passes defaultMonth={currentValue} so reopening the popover on an already-selected date shows that date's month, not always today's — react-day-picker's own default otherwise anchors to today's month regardless of the current selection.
Usage
Icon Button
import IconButton from '@vinyasa/form/icon-button';
<IconButton aria-label="Delete" icon={<TrashIcon />} variant="ghost" />;aria-label is a required prop — an icon-only button has no other accessible name.
Toggle Button
import ToggleButton from '@vinyasa/form/toggle-button';
<ToggleButton pressed={bold} onPressedChange={setBold}>
<BoldIcon />
</ToggleButton>;Wraps Radix's Toggle with asChild, passing Button as the child — Radix owns press/keyboard/aria-pressed, Button owns the visual chrome. pressedVariant/unpressedVariant (default 'primary'/'outline') pick which Button variant renders for each state.
Input, Search Input, Password Input, Number Input, Textarea
import Input from '@vinyasa/form/input';
import SearchInput from '@vinyasa/form/search-input';
import PasswordInput from '@vinyasa/form/password-input';
import NumberInput from '@vinyasa/form/number-input';
import Textarea from '@vinyasa/form/textarea';
<Input label="Email" error="Enter a valid email address." />
<SearchInput label="Search" value={query} onChange={handleChange} onClear={() => setQuery('')} />
<PasswordInput label="Password" />
<NumberInput label="Quantity" min={0} max={10} />
<Textarea label="Bio" autoResize />Checkbox, Switch, Radio Group
import { Checkbox } from '@vinyasa/form/checkbox';
import { Switch } from '@vinyasa/form/switch';
import { RadioGroup } from '@vinyasa/form/radio-group';
<Checkbox label="Accept terms" checked={accepted} onCheckedChange={setAccepted} />
<Switch label="Enable notifications" defaultChecked />
<RadioGroup
options={[
{ value: 'sm', label: 'Small' },
{ value: 'md', label: 'Medium' },
]}
value={size}
onValueChange={setSize}
/>Slider, Range Slider
import { Slider } from '@vinyasa/form/slider';
import RangeSlider from '@vinyasa/form/range-slider';
<Slider label="Volume" value={[volume]} onValueChange={([v]) => setVolume(v)} />
<RangeSlider value={range} onValueChange={setRange} minLabel="Low" maxLabel="High" />RangeSlider is a convenience wrapper around Slider, not a separate implementation — Radix's Slider already supports any number of thumbs via a value array; RangeSlider just fixes it at two with distinct min/max accessible labels.
OTP Input
import OtpInput from '@vinyasa/form/otp-input';
<OtpInput label="Verification code" length={6} onComplete={handleSubmit} />;Select
import { Select } from '@vinyasa/form/select';
<Select
label="Size"
options={[{ value: 'md', label: 'Medium' }]}
placeholder="Choose a size"
value={size}
onValueChange={setSize}
/>;Combobox, Autocomplete
import { Combobox } from '@vinyasa/form/combobox';
import { Autocomplete } from '@vinyasa/form/autocomplete';
<Combobox label="Framework" options={frameworkOptions} value={framework} onValueChange={setFramework} />
<Autocomplete label="State" suggestions={states} value={state} onValueChange={setState} />The entire difference between the two: Combobox only ever commits one of its own options; Autocomplete commits whatever text is in the field, with suggestions as a shortcut for filling it in rather than a constraint on it.
Multi Select
import { MultiSelect } from '@vinyasa/form/multi-select';
<MultiSelect
label="Favorite colors"
options={colorOptions}
value={colors}
onValueChange={setColors}
/>;Selected values render as removable chips in the trigger; picking an item in the popover toggles membership without closing (unlike Combobox, which closes on selection).
Tags Input
import TagsInput from '@vinyasa/form/tags-input';
<TagsInput label="Skills" value={skills} onValueChange={setSkills} max={10} />;Free-text tokens — press Enter or comma to commit the current draft as a tag, Backspace on an empty draft removes the last one.
Date Picker, Time Picker
import { DatePicker } from '@vinyasa/form/date-picker';
import { TimePicker } from '@vinyasa/form/time-picker';
<DatePicker label="Date of birth" value={date} onValueChange={setDate} />
<TimePicker label="Meeting time" value={time} onChange={handleChange} />dateFormat (a date-fns format string, default 'PPP') controls how the selected date renders on the trigger.
Color Picker
import { ColorPicker } from '@vinyasa/form/color-picker';
<ColorPicker label="Brand color" value={color} onValueChange={setColor} />;A hex-only picker (react-colorful's HexColorPicker + HexColorInput) — the trigger shows a swatch plus the current hex value, and the popover's text input keeps typed hex codes in sync with the visual picker.
File Upload, Dropzone
import { FileUpload } from '@vinyasa/form/file-upload';
import { Dropzone } from '@vinyasa/form/dropzone';
<FileUpload label="Attachments" value={files} onValueChange={setFiles} accept={{ 'application/pdf': ['.pdf'] }} />
<Dropzone label="Attachments" value={files} onValueChange={setFiles} maxSize={5 * 1024 * 1024} />Both wrap the same useDropzone hook and share their file-list styling (file-upload/FileUpload.css.ts) — File Upload is a compact button trigger, Dropzone a full drag-and-drop area; pick whichever chrome fits the layout.
Rich Text Editor
import { RichTextEditor } from '@vinyasa/form/rich-text-editor';
<RichTextEditor label="Description" value={html} onValueChange={setHtml} />;Wraps Tiptap (StarterKit) with a toolbar built from this package's own Toggle Button (bold/italic/strikethrough/lists/blockquote/undo/redo). value/onValueChange are HTML strings; an externally-set value is pushed into the editor via setContent only when it actually differs from the editor's current content, so it doesn't fight the user's own typing.
Subpath imports
Every component is imported by its own subpath (e.g. @vinyasa/form/date-picker) — there is no root @vinyasa/form entry, so import { X } from '@vinyasa/form' fails to resolve. See "This package has no root export" above for why.
Development
From the repository root:
pnpm --filter @vinyasa/form build
pnpm --filter @vinyasa/form test
pnpm --filter @vinyasa/form lint
pnpm storybook # Form/*