Hi! I'm Iakov, UI Kit Lead at Exante. At work, we maintain a proprietary design system -- powerful, tailored to our specific needs, but closed-source. In my spare time, I rethought a number of solutions from my day job, reworked them into general-purpose patterns, and packaged them as an open-source library -- Vacano UI.
64 components, 17 form wrappers, 1800+ icons, 10 validators, documentation for humans, and an MCP server for AI assistants.
Philosophy
There are plenty of UI libraries. Why another one?
Most UI kits fall into two camps. Headless libraries give you logic without styles -- powerful and flexible, but you're on your own for the visuals. Opinionated libraries give you polished components out of the box, but lock you into a rigid design system that's hard to escape.
Vacano UI sits in between. Components ship with a ready-made look and work out of the box. But every sub-element is accessible for styling through typed classname slots -- no !important, no nested selectors, no digging through the DOM inspector. Want to change the trigger color on a Select? Pass classnames={{ trigger: 'my-trigger' }} and write plain CSS. TypeScript tells you which slots are available for each component.
Second principle -- minimal ceremony to get started. No global ThemeProvider, no createTheme, no token config that everything depends on. Install the package, import a component, render it. Providers are only required for the components that genuinely need them: Confirmation, Notification, Toastr, SaveProgress, NotifyConfirmation -- they use context and hooks because they manage global state. The other 59 components are fully autonomous.
Third principle -- every component is finished, not half-baked. Not "here's a <select> with some classes, good luck," but a complete solution with all the edge cases that surface in production. Dropdown clipped inside a modal? Portal. Date localization? Intl.DateTimeFormat, zero dependencies. OTP input on a mobile keyboard? maxLength hack. Validation error breaks the layout of adjacent fields? CSS Grid subgrid. Details on each component below.
Getting Started
pnpm add @vacano/ui @emotion/react @emotion/styled
import { GlobalStyle, Button, Input, Select } from '@vacano/ui'
function App() {
return (
<>
<GlobalStyle />
<Input label="Name" placeholder="Enter name" />
<Select label="City" options={cities} onChange={setCity} />
<Button variant="normal">Submit</Button>
</>
)
}
Four entry points:
-
@vacano/ui-- all components -
@vacano/ui/form-- 17 react-hook-form wrappers (generic, typesafe) -
@vacano/ui/icons-- 1800+ Lucide icons -
@vacano/ui/lib-- types, constants, hooks, 10 Yup validators
Documentation
A library without documentation is just source code on GitHub. You can figure it out, but why should you have to? I spent no less time on the docs than on the components themselves, and I consider documentation an equal part of the product.
Documentation for Humans
ui.vacano.io -- a VitePress site where each of the 64 components has its own page with a consistent structure:
- Description -- what the component does and when to use it
-
Props table -- every prop with its exact TypeScript type (
'normal' | 'danger', not juststring), default value, and description - Classname slots table -- all available slots for sub-element styling
- Usage examples -- not minimal "hello world" snippets, but realistic scenarios with full imports
- Related components -- navigation to alternatives (Select → Autocomplete → MultiSelect)
Beyond components, the docs cover utilities: constants (colors, breakpoints, z-indexes), media helpers (mediaUp, mediaDown, mediaBetween), hooks, and validation.
There's also a Storybook where you can interact with every component, tweak props, and see how it behaves in different states.
MCP Server -- Documentation for AI Assistants
Good documentation isn't just useful for humans. MCP (Model Context Protocol) is a standard from Anthropic that lets AI assistants connect to external data sources. Vacano UI provides an MCP server that gives the assistant access to the entire documentation: all components, all props, constraints, examples, and recommendations.
I deliberately wrote the docs for two types of readers: humans and AI agents. These aren't conflicting requirements -- precise types, complete examples, and explicit constraints help both.
Setup for Claude Code (.mcp.json in your project root):
{
"mcpServers": {
"vacano-ui": {
"type": "http",
"url": "https://tools.vacano.io/ui/mcp"
}
}
}
For Cursor (.cursor/mcp.json):
{
"mcpServers": {
"vacano-ui": {
"command": "npx",
"args": ["-y", "@anthropic-ai/mcp-remote@latest", "https://tools.vacano.io/ui/mcp"]
}
}
}
For Windsurf -- similar setup via .windsurf/mcp.json.
Once connected, your AI assistant knows all 64 components, their props, constraints, the 17 form wrappers and their generic typing, all entry points and correct import paths. Instead of hallucinating non-existent props, the assistant queries the MCP server and gets up-to-date documentation.
You can say: "Build a registration form with email, password, country selection, and terms acceptance. Use Vacano UI." -- and get working code with correct imports, typing, and all the nuances.
What's Inside
64 components split into six categories. Below is an overview focused on mechanics and non-trivial solutions. Full documentation with props, examples, and API is at ui.vacano.io.
Forms
19 base components and 17 form wrappers for react-hook-form.
The base components are Input, Select, Autocomplete, DatePicker, Tags, Textarea, Checkbox, Toggle, Radio and their Card/Group variants, OtpCode, FileUpload, MultiSelect. Each works standalone with controlled and uncontrolled state.
Form wrappers eliminate react-hook-form boilerplate. Each one is generic: name is typed via FieldPath<T>, autocompletes from the form type, a typo in the field name is a compile error. Validation errors display automatically -- error text under text inputs, red variant on checkboxes, variant error on entire groups. No need to manually extract errors from formState or pass field.value ?? false for boolean controls -- the wrappers handle it.
FieldRow aligns multiple fields in a row via CSS Grid subgrid. Three grid rows: label, input, message. When one field shows a validation error, the text occupies the third row. Adjacent fields don't shift: their inputs stay on the second row, the third row is empty but its size is determined by neighbors. Layout stays intact.
10 Yup validators are exported from @vacano/ui/lib: email, password (8+ chars, letter + digit), phone (international format), creditCard (Luhn algorithm, 13-19 digits), url, slug, ipv4, hexColor, minAge (by birth date), noSpaces.
OtpCode -- one-time code input. Auto-advance between cells, clipboard paste support, Backspace navigates to previous cell, arrow key navigation. Under the hood -- maxLength={2} instead of 1, because on some mobile keyboards onChange doesn't fire with maxLength={1} when the cell is already filled.
Tags supports freeSolo mode -- users can create tags not in the options list. Tab creates a tag, Backspace on an empty input removes the last one. The dropdown filters in real time and hides already selected tags.
Autocomplete is built for server-side search: accepts an async onSearch callback, debounceMs for debouncing (no lodash needed), minChars for minimum characters before querying. Shows a spinner while loading.
FileUpload -- drag & drop with type and size validation. If three out of five files pass and two don't -- it accepts the three, rejects the two, and fires a separate onReject callback with the reason.
Data Display
Avatar -- not just a picture in a circle. Three-tier fallback: image → icon → initials. Initials are extracted smartly: "Yakov Salikov" becomes "YS", "Admin" becomes "Ad". If the image fails to load (network error, 404), the component automatically switches to the next fallback via onError. AvatarGroup overlaps avatars with a -25% offset and shows "+N" for the rest.
Badge -- a positioned indicator on top of an element. 4 placements (top-right, top-left, bottom-right, bottom-left), 2 shapes (circle, rectangle), 3 variants (solid, flat, bordered). Can display a number, a dot, or arbitrary content. Automatically adds a white outline so the badge doesn't blend with the background.
Card -- not just a container with a shadow. Three sub-components (Header, Body, Footer) for structure. pressable mode scales the card to scale(0.98) on click. hoverable adds shadow on hover. blurred enables backdrop-filter: blur(10px) on the background. footerBlurred -- separate blur on just the footer.
Skeleton supports two animations: pulse (opacity 0-1-0 flicker) and wave (gradient sweeping left to right). circle mode automatically makes width equal to height.
Timeline -- event chronology. Each entry can have title, description, content, and actions. Actions is a slot for buttons (edit, delete). Description always sits under the title, actions float right -- they don't push the text down.
StepLog -- step-by-step execution log. Each step has a status (success, error, running, pending), duration, and expandable log lines with line numbers. Pending steps are non-interactive, others expand on click.
DateRange displays a date range with localization via Intl.DateTimeFormat. "January 2025 — March 2025" in English, "Январь 2025 — Март 2025" in Russian, "2025年1月 — 2025年3月" in Japanese. If no end date is provided, it shows configurable text ("Present Time" by default).
Feedback
Notification and Toastr -- two notification systems with different mechanics. Notification is a "one at a time" queue. It shows one notification; after it closes (by timer or manually), the next one appears. A 100ms pause between shows for smoothness. Call show() three times in a row and the user sees three notifications sequentially. Toastr is a "show all" stack. Multiple toasts are visible simultaneously. If the visible limit is exceeded, the rest queue up and appear as others close. Each toast has its own timer (or none -- you can make a toast without auto-dismiss). A "+N" counter shows the number of queued notifications. Both systems work through the Provider pattern and hooks.
Confirmation -- not a component, but a hook. Call confirm('Delete?', async () => { ... }) and a dialog appears with a blur overlay covering the entire page, blocking the interface. If the callback returns a Promise, the "Confirm" button shows a spinner and "Cancel" is disabled until completion. Dismissable via Escape and overlay click. Enter and exit animations -- 200ms.
Modal renders via portal to document.body. Overlay with semi-transparent background, content centered with max-height: calc(100vh - 32px) and scroll. Drawer is the same idea but slides in from one of four sides (left, right, top, bottom) with a configurable size.
PendingScreen -- a waiting screen for long-running operations. Instead of a spinner, it shows animated phrases in the style of a split-flap airport departure board. Each letter independently cycles through random characters, then "snaps" to the correct one -- in a wave from left to right. 78 built-in phrases in random order without repeats (Fisher-Yates shuffle). Full cycle is about 4.5 minutes. You can pass custom phrases and adjust the interval.
Tooltip positions itself with viewport awareness: if there's not enough space above, it shows below. Coordinates are clamped to screen edges with 8px padding to prevent clipping.
Navigation
Accordion expands via CSS Grid 0fr → 1fr with transition -- no max-height: 9999px. Works with any content height. Two visual variants: outlined (dividers between sections) and splitted (separate cards with a gap). multiple mode allows keeping several sections open. Controlled and uncontrolled state.
Pagination calculates the visible page range via an algorithm with siblings (pages around current) and boundaries (pages at edges) parameters. Ellipsis between them. An animated cursor smoothly slides to the active page via transform: translateX(). loop mode -- after the last page, the next button goes to the first.
Breadcrumbs collapse long chains: configurable number of items at the start and end, ellipsis in between. Separator is customizable.
Stepper -- step-by-step progress. Three visual states: active, completed, pending. Horizontal and vertical orientation. Lines between steps change color on completion. Optional click-to-navigate.
MenuButton -- an animated hamburger. Three bars smoothly transform into a cross: top rotates -45°, middle fades out (opacity 0), bottom rotates +45°.
Layout
Container -- responsive wrapper with max-width by breakpoints (sm: 640, md: 768, lg: 1024, xl: 1280, 2xl: 1536). Divider -- a horizontal line with an optional centered label (two line segments with text between them). Panel -- a container with a dashed border, label, title, and description; two variants: light and dark. ShellScreen -- a full-screen template with a decorative background grid, animated rings around an icon, and sections for logo, header, and content.
Utilities
FieldLabel and FieldMessage -- building blocks for custom fields. FieldLabel renders a label with an optional asterisk * for required. FieldMessage -- text below the field colored by variant: gray (normal), red (error), green (success), yellow (warning). Both return null if there's no content -- no conditional rendering needed on the outside.
ImageCropper -- a React wrapper around my own framework-agnostic library hq-cropper. hq-cropper isn't tied to any framework -- it works with vanilla JS, Vue, Svelte, anything. In Vacano UI it's wrapped in a useImageCropper hook with lazy initialization: the cropping library only loads on first open, not on mount. Returns both base64 and blob. Configurable output size, compression, and file size limit.
KeysBindings and KeySymbol -- keyboard shortcut display. Platform-aware symbols: Meta → ⌘ on Mac, Win on Windows. Control → ⌃ on Mac, Ctrl on Windows. And so on for all modifiers. The useKeyBinding hook tracks key combinations via a Set of pressed keys -- fires only when all keys in the combination are held simultaneously.
SplitFlapText -- the "airport departure board" animation. Can be used independently from PendingScreen -- for example, for stock tickers, status displays, or timers.
Cross-Cutting Patterns
All 64 components share common principles:
-
Two sizes --
compactanddefault -
Variants --
normal,success,warning,danger, and others -
Classname slots -- typed
classnamesfor sub-element styling - data-test-id -- a single prop for test automation
- ref forwarding -- React 19 style via props
-
Portals -- 6 components with
portalRenderNodefor rendering dropdowns aboveoverflow: hidden -
Z-indexes -- shared
Z_INDEXconstant for all layers: dropdown (100) → modalOverlay (1000) → modal (1001) → portalDropdown (1002) → confirmation (1003) - Keyboard -- Escape to close, arrows to navigate, Enter/Space to select
-
Hotkeys -- Button accepts
keyBindings={['Meta', 'S']}with platform-aware symbols -
Localization -- DatePicker and DateRange via
Intl.DateTimeFormat, 400+ locales with zero dependencies - 1800+ icons -- Lucide Icons wrappers, tree-shaking removes unused ones
Links
- Documentation: ui.vacano.io
- Storybook: ui.vacano.io/storybook
- GitHub: github.com/vacano-house/vacano-ui
- npm: @vacano/ui
- License: MIT
If you find the library useful, a star on GitHub helps the project grow. Happy to answer questions and hear feedback in the comments.
Top comments (4)
The classname slots pattern is a really clean middle ground. I've been building a finance app with React+Vite and kept bouncing between CSS Modules (too much boilerplate for simple overrides) and inline styles (loses pseudo-classes). The typed slot approach where TypeScript tells you what's available solves the discovery problem — you don't need to inspect the DOM to figure out what you can target.
Genuine question about the @emotion dependency though: with the shift toward zero-runtime CSS (vanilla-extract, Panda CSS, even just plain CSS with :has() and container queries), do you see a path to making emotion optional? For projects where bundle size is critical (I ship a PWA that needs to work on spotty mobile connections), adding emotion as a peer dep is the one thing that made me hesitate.
The MCP server concept is fascinating. Writing docs that serve both humans and AI agents simultaneously — I hadn't thought about documentation as an API surface for LLMs before. Does the MCP server expose component relationship graphs? Like if an agent asks "I need a searchable dropdown with multi-select," can it navigate Select → Autocomplete → MultiSelect through the MCP tools, or does it rely on the text descriptions?
The "every component is finished" philosophy resonates hard. The number of times I've pulled in a date picker only to discover it doesn't handle locale-specific formats or breaks inside a modal with overflow:hidden... Your portal + Intl.DateTimeFormat approach sounds like exactly the kind of production-tested solution that tutorials skip over.
Thanks for the thoughtful comment — you clearly dug into the details, so let me answer properly.
On Emotion dependency. Fair concern. Right now Emotion is a peer dependency, not bundled — so if your project already uses it, the overhead is zero. If it doesn't, yes, it's an extra
~12KB gzipped. I've been thinking about this. The realistic path isn't "make Emotion optional" (that would mean maintaining two styling backends), but rather migrating to a build-time
solution in a future major version. For now, the tradeoff is: Emotion gives us runtime theme access, the css prop, and easy dynamic styles based on transient props ($variant, $size,
$disabled) — which is what makes the typed classname slots work so cleanly. Replacing that with vanilla-extract or Panda would require rethinking how slots and dynamic variants are
implemented. It's on the radar, but not before v2.
On MCP and component relationships. The MCP server exposes the full documentation for each component, and every component page has a "Related Components" section — so when an agent
reads the Select docs, it sees links to Autocomplete and MultiSelect with descriptions of when to use each. It's not a graph API with traversal — it's structured text that agents
navigate through those explicit links. In practice, if you ask Claude "I need a searchable dropdown with multi-select," it queries the MCP server, reads Select, follows the related
components to MultiSelect and Autocomplete, and picks the right one. The key insight was making those relationships explicit in the docs rather than hoping the agent infers them.
On "every component is finished." Glad that resonates. The portal + Intl.DateTimeFormat combo is exactly the kind of thing that sounds boring on paper but saves hours in production.
DatePicker supports 400+ locales with zero extra dependencies — no moment, no date-fns, just the browser's built-in formatter. And 6 components (Select, Autocomplete, MultiSelect,
DatePicker, Tags, Tooltip) support portalRenderNode for rendering dropdowns outside overflow: hidden containers, with a shared Z_INDEX constant that keeps everything layered correctly.
Really appreciate the detailed breakdown — especially the reasoning behind keeping Emotion for now. The transient props pattern making typed classname slots work cleanly is a strong argument. Build-time migration for v2 sounds like the right call.
The MCP approach with explicit Related Components links is clever. Structured navigation beats hoping agents infer connections.
And 400+ locales via Intl.DateTimeFormat with zero extra deps — that is the kind of decision that compounds.
Curious: when you move toward build-time CSS in v2, are you leaning toward vanilla-extract or something newer like Panda or StyleX?
On v2 styling — I've been looking at all three seriously. Here's where I am:
vanilla-extract is the most natural migration path for us. Our current architecture is styled() components with transient props ($variant, $size) and helper functions that return style
values. vanilla-extract's Recipes API maps almost 1:1 to this pattern — you define base + variants + compoundVariants and get a typed function that returns class names. The refactor
would be mechanical: convert 60 styled.ts files to *.css.ts with recipes. Architecture stays the same, runtime disappears.
Panda CSS is great if you're building from scratch with an atomic/utility mindset (it comes from the Chakra UI author, and you can tell). But for migrating an existing component
library with explicit variant logic and classname slots — it's a paradigm shift, not a migration. Too much would change for the consumer API to stay stable.
StyleX is the most interesting wildcard. Meta uses it across Facebook, Instagram, WhatsApp at massive scale, and it's been getting real traction (Figma, Snowflake adopted it). It's
also zero-runtime and compile-time — but its API is closer to atomic CSS. stylex.create() produces optimized atomic classes that get deduplicated across the entire bundle. Powerful for
apps, but for a component library where consumers need to override sub-element styles via classname slots — I'd need to validate that our customization model still works cleanly.
So the honest answer: vanilla-extract for v2, unless StyleX solves the component library customization story better by the time I get there. The goal is zero runtime overhead without
breaking the consumer API — typed classname slots and variant props should work exactly the same way after migration.