@holakirr/snow-ui
v5.3.4
Published
SnowUI is a React component library designed by ByeWind, implemented and improved by holakirr for creating beautiful and interactive user interfaces.
Readme
Holakirr Snow UI
SnowUI is a React UI components library implementation of SnowUI design kit by ByeWind. Implemented and improved by holakirr. Based on TailwindCSS. Build your design using it
Take a look at my CV =)
Features
- 🎨 Built with Tailwind CSS for utility-first styling
- 📚 Storybook for component documentation and development
- 🔍 TypeScript for type safety
- ✅ Comprehensive testing setup:
- Unit testing with Vitest
- E2E testing with Playwright
- 🚀 tsdown (Rolldown) library build, checked with publint and are-the-types-wrong
- ⚡️ Powered by Bun for fast package management and running scripts
Getting Started
Installation
To get started, install Holakirr Snow UI package via package manager of your choice:
bun add @holakirr/snow-ui react react-domPeer dependencies: react and react-dom 19. Nothing else is required — the library is not tied to any form or state library.
Then import the styles, in one of two ways.
Without Tailwind CSS: index.css ships precompiled (Tailwind's preflight, the theme and every class the components use):
import '@holakirr/snow-ui/index.css'With Tailwind CSS v4 in your project: import the theme into your stylesheet after Tailwind. theme.css adds the package's components to Tailwind's sources itself (an @source relative to its own file, so it works wherever the package is installed), so Tailwind generates the classes they use; with 5.0 or older, also add @source "../node_modules/@holakirr/snow-ui/dist"; (relative to your stylesheet):
@import "tailwindcss";
@import "@holakirr/snow-ui/theme.css";theme.css is the same theme without Tailwind itself: the design tokens as theme variables (so bg-black-10, text-14 or rounded-12 work in your own markup), the theme scopes, the dark variant, the glass* and focus-ring utilities, the base rules and react-day-picker's stylesheet (for Calendar). Your Tailwind generates one set of utilities for your code and the components, instead of two copies of preflight and overlapping utilities. Import one stylesheet: index.css or theme.css (or theme-core.css, below). Note that theme.css redefines dark: to follow scoped themes (data-theme or the light / dark classes, then the OS preference) and overrides Tailwind's --font-sans, --color-black and --color-white. With a prefix (@import "tailwindcss" prefix(tw)), use index.css instead: the components' classes are unprefixed, so your Tailwind wouldn't generate them.
Without the package's components. If you don't render the package's components — you copy them from the @snow-ui registry, or only use the tokens — import @holakirr/snow-ui/theme-core.css instead: the same theme without that @source, so Tailwind doesn't generate the classes of every component.
Cascade layers. Both stylesheets keep every rule in Tailwind's cascade layers (theme, base, components, utilities; react-day-picker's stylesheet is in components), so your own unlayered CSS overrides them whatever the load order or specificity, and your utilities override the base and component rules.
Fonts (optional). The design uses Inter with the ss01 / cv01 OpenType features, which the Google Fonts build of Inter doesn't include. fonts.css self-hosts Inter 4.1 (the rsms build, SIL Open Font License): variable weight 100–900 with the optical size pinned at 14 (the text optical size the design uses at every size), font-display: swap, split by unicode-range so browsers only download the scripts a page uses (about 69 kB for Latin, plus 8 kB when a page shows arrows or keyboard symbols such as CommandPalette's ↩ or a shortcut's ⌘):
import '@holakirr/snow-ui/fonts.css'
// Only if your app has italic text (the design has none):
import '@holakirr/snow-ui/fonts-italic.css'Import it from JavaScript or through a bundler (Vite, webpack, Next.js), which resolve its relative url()s to the font files. Don't @import it into a stylesheet built by the Tailwind CLI: the CLI inlines it and keeps the url("./fonts/…") as they are, so they point next to your output file and 404. The font files are in @holakirr/snow-ui/fonts/*. To load Inter yourself, use a build with those features; the stylesheets only set font-family: Inter, sans-serif, the font-feature-settings and font-optical-sizing: none (see Design tokens).
Preloading the font. Almost every page needs the Latin file for its first paint, and the browser only discovers it once the CSS has loaded and a text node uses it. Preload it so the text doesn't render in the fallback font and then swap:
<link rel="preload" href="/assets/inter-latin-normal-[hash].woff2" as="font" type="font/woff2" crossorigin />- The
hrefmust be the URL your bundler emits for@holakirr/snow-ui/fonts/inter-latin-normal.woff2, so that the preload and the@font-facerule share one request. In Vite:import interLatin from '@holakirr/snow-ui/fonts/inter-latin-normal.woff2?url'. In React 19 you can then callpreload(interLatin, { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' })fromreact-domduring render; React adds the<link>to the document head, also when rendering on the server. - Keep
crossorigin, even on the same origin: fonts are always fetched in CORS mode, and a preload without it is not reused (the font downloads twice). - Preload only the Latin file (
inter-latin-normal.woff2). The other subsets, the symbols and the italic faces load when a page shows those characters.
Basic example
Buttons are build using the Button component:
import { Button } from '@holakirr/snow-ui'
function App() {
return (
<Button variant="filled" size="md">
Click me
</Button>
)
}Forms
Form* components are library-agnostic: pass the validation state to FormItem and the label, control, description and message are wired up (ids, aria-invalid, aria-describedby).
import { Form, FormControl, FormItem, FormLabel, FormMessage, Input } from '@holakirr/snow-ui'
<Form onSubmit={handleSubmit}>
<FormItem error={errors.email}>
<FormLabel>Email</FormLabel>
<FormControl>
<Input name="email" />
</FormControl>
<FormMessage />
</FormItem>
</Form>This works with any form library (TanStack Form, Formik, Conform, server actions…). Adapters can provide the state for nested items with <FormFieldState value={{ name, error, invalid }}>.
react-hook-form
An optional adapter is available as a separate entry point. Install react-hook-form yourself; the main entry doesn't import it.
import { Form, FormField, FormItem, FormControl, FormMessage } from '@holakirr/snow-ui/react-hook-form'Design tokens
index.css and theme.css implement the SnowUI Figma tokens (the SnowUI-Light and SnowUI-Dark modes) as Tailwind theme variables. They are generated from W3C Design Tokens (DTCG) files in tokens/, the source of truth, which other tools (Style Dictionary, Terrazzo, Figma plugins) can read too. Storybook's "Foundations" pages list every token with its Figma name and both mode values.
- Theme: light by default; dark with
data-theme="dark"or thedarkclass (as next-themes and shadcn/ui set it) on<html>or on any element (see Scoped themes), or with the OS dark preference when<html>sets no mode (data-theme,lightordarkclass).color-schemefollows, so native controls and scrollbars match. - Colors:
primary(black in light, indigo in dark),black,whiteand their Figma alpha steps (black-80,black-40,black-20,black-10,black-4, same forwhite),background-1..3,surface-1..3,color-1,color-2,static-white,static-black, the secondary colours (purple,indigo,blue,cyan,mint,green,yellow,orange,red), and three accessible text colours (see below):indigo-text,red-textandtext-secondary(thetext-secondaryutility;var(--color-text-secondary)) for secondary text, which Figma draws in Black/40%. Use them asbg-black-10,text-secondary,border-black-10orvar(--color-black-10). In dark mode,black-10andblack-4get the design's stronger alpha (15% and 10%); the Tailwind modifiersblack/10andblack/4keep one alpha in both modes. - Typography:
text-12…text-64(the Figma text styles: font size and line height), Inter withfont-feature-settings: "ss01" 1, "cv01" 1andfont-optical-sizing: none: the Figma kit uses the "Inter" family (text optical size) at every size, not "Inter Display", so large text keeps the text shapes instead of following the variable font'sopszaxis. - Radius:
rounded-4…rounded-80(the Figma corner radius scale). - Spacing: the Figma spacing and size values are multiples of 4px, so Tailwind's spacing utilities (
p-1= 4px,gap-3= 12px,size-10= 40px) cover them. - Cursors: a base rule gives buttons and ARIA controls (
option,menuitem,tab,radio,checkbox,switch,label[for]…) the pointer cursor, which Tailwind v4's preflight no longer sets, and disabled elements (:disabled,aria-disabled="true",data-disabled) the default arrow cursor.cursor-*utilities override it. The kit's disabled state is the whole control at 20% opacity with the default arrow cursor; Disabled colours are retained; the cursor follows the kit. - Focus: the
focus-ringutility is the keyboard focus indicator of every component: on:focus-visibleit draws the Figma "Focus" ring (ring-4 ring-black-80). Use it on your own focusable elements:<button className="focus-ring">. - Contrast: the form controls' boundaries and placeholders are the
control-border,control-border-strongandplaceholdertokens: the Figma colours by default, WCAG AA ones with more contrast (see Contrast). Thecontrast-more:variant follows the same scopes. - Target size: the
hit-areautility gives a small control an invisible pointer hit area of at least 24×24px (WCAG 2.5.8), centred on it; the element needsrelative. BareButtons,Switch, theSliderthumb, sortableTableheaders, line tabs and theSearchclear button use it. - Motion: with the OS setting
prefers-reduced-motion: reduce, the overlays fade instead of sliding or zooming, the Accordion opens at once, and the Skeleton pulse, the Button press, the Switch thumb, the Dialog and AlertDialog zoom, the Accordion chevron, the Avatar photo's hover zoom and the Sidebar's collapse stop moving (WCAG 2.3.3); the Spinner and Progress pulse instead of turning or sliding. Storybook's Foundations › Motion page lists the animation tokens. - Scrollbars: the
scrollbar-snowutility draws the Figma "Scrollbar" on a scroll container, with no track: a 4px rounded thumb inblack-10that widens to 8px incontrol-borderunder the pointer and while it is dragged (with more contrast the resting thumb iscontrol-bordertoo, 3:1 or more). Chrome, Edge and Safari draw the kit's thumb in an 8px gutter; Firefox can't style a hovered thumb, so its thin scrollbar keeps its own width and turnscontrol-borderwhile the pointer is over the container. Forced-colors mode keeps the system scrollbar. See the deviations for where it differs from the kit. The library's own scroll areas use it: the Select, Combobox and MultiSelect lists, DropdownMenu and its submenus (which now scroll when taller than the room on their side), the CommandPalette results andSidebarContent; ContextMenu has it for when you make it scroll (max-h-(--radix-context-menu-content-available-height) overflow-y-auto, with portalled submenus). Their gutter isauto: a list that scrolls is 8px narrower at its end, one that doesn't keeps its padding on both sides. It is inindex.csstoo. - Effects:
shadow-1,shadow-2,shadow-glow,shadow-glass-1,shadow-glass-2,inset-shadow-inner, the Figma focus ringring-4 ring-focus(orshadow-focus), the background blursbackdrop-blur-bg-40(20px) andbackdrop-blur-bg-100(50px), andglass/glass-1/glass-2, which approximate Figma's Glass effects with a fill, a background blur and a shadow.
Inter itself is opt-in: import @holakirr/snow-ui/fonts.css (see Installation) or load a build with the ss01 / cv01 features yourself.
Old token names (brand, bg1…bg5, brand-hover and the shadcn-style background, foreground, muted, accent, destructive, input, ring…) still work as deprecated aliases; see the changelog for the mapping.
Scoped themes
data-theme (or the light / dark class) works on any element, not only <html>: the tokens of an element come from its nearest scope ancestor, so a subtree can use the other mode, and data-theme="light" inside a dark subtree switches back.
<html data-theme="light">
…
<aside data-theme="dark" className="bg-background-1 text-black">
{/* dark tokens: background-1 is #333, black is white */}
<div data-theme="light">{/* light again */}</div>
</aside>
</html>- A scope sets the tokens and
color-scheme, not a background or text colour: give itbg-background-1 text-black(or your own) to paint it. - Without a mode on
<html>, the page follows the OS preference;<html data-theme="light">or"dark"(orclass="light"/"dark", e.g. from next-themes withattribute="class") pins it. Scopes inside work either way. dark:utilities follow the same scopes, with one limit: a dark scope inside a light scope inside a dark scope gets the dark tokens but notdark:utilities. Prefer the tokens, which switch at any depth.- Overlays are portalled: the content of
Dialog,AlertDialog,Sheet,Popover,DropdownMenu,ContextMenu,Tooltip,Select,CommandPalette,Combobox,MultiSelect,DatePickerandDateRangePickerrenders at the end of<body>, outside your scope. A plaindata-themeattribute leaves it in the theme of<html>; aThemeScope(<ThemeScope theme="dark">, orasChildon your element) scopes the tokens and passes the theme to the overlays opened inside it, as theirdata-theme.SnowUIProvider'sthemedoes the same without an element, and adata-themeon a*Contentcomponent wins. Toasts render inside<Toaster />, so they take the theme of wherever you place it.
Contrast
The form controls follow the Figma kit by default, and its rings, strokes, Switch track and placeholders are under WCAG AA (see Known gaps). With more contrast they meet it, in both themes:
- with the OS setting behind
prefers-contrast: more(macOS and iOS "Increase contrast", and similar OS or browser settings), unless<html data-contrast="standard">; - inside any element with
data-contrast="more"(your app's own setting), scoped likedata-theme:data-contrast="standard"inside switches a subtree back, and contrast and theme scopes combine at any depth. Portalled overlays follow aThemeScope's (orSnowUIProvider's)contrast, like itstheme.
| Token | Standard (Figma) | More contrast | Used by |
| --- | --- | --- | --- |
| control-border | Black/20% (1.6:1) | Black/50% (≥ 3.79:1); White/50% in dark mode (≥ 3.3:1) | Unchecked Checkbox and Radio rings, text field and Select strokes, the Switch's off track; with more contrast only, the Slider bar's stroke and the range's track |
| control-border-strong | Black/40% | Black/80% (12.6:1; 8.7:1 in dark mode) | Their hover and focus states, the hovered Search icon; with more contrast only, the border of the Slider range's thumbs |
| placeholder | Black/20% (1.6:1) | Black/60% (≥ 5.25:1); White/75% in dark mode (≥ 4.63:1) | Placeholders of Input, InputSmall, Textarea, Search, and the Search icon |
| control-border-invalid | Secondary/Red (3.36:1 on white; 2.32:1 at worst, on a dark gray field) | red-text (≥ 4.53:1; ≥ 3.22:1 in dark mode) | The stroke of an invalid (aria-invalid) text field, Select, Checkbox, Radio, Switch or Slider |
With more contrast the 0.5px strokes are 1px and the text fields' focus stroke 2px, the gray InputSmall and Search fields and the Slider bar get a stroke (their Black/4% fill is 1.1:1), the Slider range gets a control-border track and control-border-strong thumb borders, the Switch thumb is the per-mode white (black on the dark-mode indigo track, 10.14:1), and the highlighted menu item and CommandPalette option get a 2px black-80 ring. The contrast-more: variant (it replaces Tailwind's, which only reads the OS preference) follows the same scopes, for your own controls. The ratios are asserted from the tokens in both themes (src/foundations/contrast.test.ts), and Storybook's tests run axe on every story with the OS preference emulated. Storybook's "Contrast" guide has every ratio and the reasoning behind the values.
Components
- Base:
Typography,KBD,IconBox,IconText,Group,Strip,Separator,Badge,Tag,Chip,TextStrip,Link,Avatar,AvatarGroup,Image,Skeleton. - Controls:
Button,Input,InputSmall,Search,Textarea,Checkbox,RadioGroup,Switch,Toggle,ToggleGroup,Select,Combobox,MultiSelect,DatePicker,DateRangePicker,Slider,Label,Form*. - Overlays:
Dialog,Sheet,Popover,DropdownMenu,ContextMenu,Tooltip,Toaster,CommandPalette,AlertDialog. - Data and navigation:
Card,Table,Tabs,Accordion,Breadcrumb,Pagination,Sidebar,ListItem,ListCard,Calendar,Scheduler. - Feedback:
Alert,Progress,ProgressCircle,Spinner.
The components that map to the SnowUI Figma base components:
IconBox— the Figma "Icon": sizes an icon, avatar or image (size12–80), optionally on a Black/4% tile (background) with abadge(a dot, or any node such as a count).IconText— an icon or avatar plus text (vertical,flip).interactiveadds the Figma "Frame" hover fill (Black/4%),activekeeps it on; withasChilda link or a<button>child is the row (navigation items, clickable rows).Group— lays out items in a row or column, 8px apart (vertical,reverse,gap), withrole="group".Strip— a row or column of equal segments (count,vertical,thickness,rounded); withvalueit is a segmented progress bar (role="progressbar", orrole="meter").Search— the 28px search field (gray/outline,sm/lg) with a search icon, a keyboard shortcut hint (shortcut={['/']}) and a clear button (also Escape) that callsonChangewith an empty value andonClear.CommandPalette— the Figma "SearchPopup": a dialog with a search combobox and a grouped listbox. Passgroupsof items (label,icon,keywords,disabled,onSelect); ↑ / ↓ move the highlight, Enter or a click selects (onSelect(item, event)— checkevent.metaKeyto open in a new tab), Escape closes.hotkey('/','mod+k') opens it,filter={false}+onQueryChange+loadingserve results from a server,emptyMessagereplaces "No results".ListItem— a row of the dashboard Notifications / Activities / Contacts lists:icon,titleand atext-secondarydescriptionor timestamp (Figma: Black/40%); anIconText, sointeractive,activeandasChildwork too.ListCard— the Figma Notifications, Activities and Contacts cards: a titled<section>of ListItem rows on the popup surface (glass-2, radius 24, 248px wide); rows withhrefare links, withonSelectbuttons.NotificationsCard,ActivitiesCardandContactsCardtake the kit's content and a translated default title (listCardsmessages).
Storybook's "Recipes/Dashboard" composes them with Sidebar, Breadcrumb, Card and Button into the SnowUI dashboard layout (sidebar, header, content, right sidebar).
Composition (asChild)
Components that render a single element take asChild (Radix Slot) to render their only child instead, e.g. a router link:
<Button asChild variant="filled" label="Docs" startContent={<BookIcon />}>
<RouterLink to="/docs" />
</Button>
<Typography asChild size={24} semibold>
<h1>Settings</h1>
</Typography>
<BreadcrumbLink asChild>
<RouterLink to="/">Home</RouterLink>
</BreadcrumbLink>
{/* A <button> child needs its own type="button" (the deprecated as="button" set it). */}
<IconText asChild interactive icon={<GearIcon />}>
<button type="button" onClick={openSettings}>Settings</button>
</IconText>
<ListItem asChild interactive title="Drew Cano" description="Online">
<button type="button" onClick={openChat} />
</ListItem>- Where:
Button,Typography,IconText,ListItem,Link,BreadcrumbLink,PaginationLink,PaginationPrevious,PaginationNext, theSidebar*parts that had it,Alert,AlertTitle,AlertDescription,AlertDialogActionandAlertDialogCancel(Buttons), and every Radix-based part (DialogTrigger,DialogClose,AlertDialogTrigger,TabsTrigger,DropdownMenuTrigger,TooltipTrigger…). - Props: the component's props go on the child; for the same prop the child's wins (
href,type…), exceptstyle, which merges, and event handlers, which compose. - Event handlers: with
asChild, a handler on the child and one on the component both run, the child's first; the component's also runs when the child's callsevent.preventDefault()(RadixSlotbehaviour), because both are yours. Behaviour a component adds itself follows the Radix convention instead: it runs after your handler and is skipped when your handler callsevent.preventDefault()— the Radix parts (aDialogTriggerdoesn't open, a menu item doesn't close the menu),SidebarTriggerandSidebarRail(no toggle),Search's Escape (no clear). - Classes: merged with the token-aware
twMerge, the child's last:<Button asChild><a className="px-8" /></Button>replaces the button'spx-3instead of fighting it in the stylesheet. - Refs: a
refon the component and arefon the child both get the element. - Content: the component's own content goes inside the child, around the child's children: Button's
label,startContentandendContent, IconText's icon, ListItem's title and description, Link's arrow and external text, the pagination arrows, Alert's icon, actions and dismiss button. - Deprecated
as:Button,Typography,IconTextandListItemstill acceptas(<Button as="a" href="/">), with a one-time development warning. It will be removed in the next major version; migrate toasChildand move element-specific props (href,type) to the child:<Button asChild><a href="/">…</a></Button>.
Controlled and uncontrolled props
State follows the Radix naming everywhere: value / defaultValue / onValueChange (Tabs, Accordion, Select, Combobox, MultiSelect, DatePicker, DateRangePicker, RadioGroup, ToggleGroup, Slider), open / defaultOpen / onOpenChange (Dialog, AlertDialog, Sheet, Popover, DropdownMenu, ContextMenu, Tooltip, Select, Combobox, MultiSelect, DatePicker, DateRangePicker, CommandPalette, SidebarProvider), checked / defaultChecked / onCheckedChange (Checkbox, Switch, menu checkbox items) and pressed / defaultPressed / onPressedChange (Toggle). Aligned in this release, with the old names kept as deprecated aliases (one development warning each, removal in the next major):
| Component | Deprecated | Use |
| --- | --- | --- |
| Button, Tag, DialogHeader | leftContent, rightContent | startContent, endContent (as Input and Search; they follow the text direction) |
| Tag | onClose | onRemove (the button is "Remove tag …") |
| Button, Typography, IconText, ListItem | as | asChild |
Search also gained onValueChange(value) next to the native onChange(event). Kept as they are, and documented: CommandPalette's search text is query / defaultQuery / onQueryChange (the same pattern for a second value); Calendar keeps react-day-picker's selected / onSelect and month / defaultMonth / onMonthChange; Scheduler's currentDate is controlled only and its onDateClick / onEventClick are required (a defaultDate and optional handlers need a breaking change, planned for the next major); the mobile open state of Sidebar isn't controllable; Toaster is driven by toast(); Input, Textarea and InputSmall are native fields (value / onChange).
Localization
Every string the components render on their own — accessible names, screen-reader text, placeholders, empty states — comes from a typed Messages object. SnowUIProvider sets it (English defaultMessages without one), along with the date-fns locale for dates.
SnowUIProvider is a client component, and a date-fns locale and function messages are functions, which a React Server Component can't pass to a client component (Next.js App Router: "Functions cannot be passed directly to Client Components"). Put the provider in a 'use client' module that imports the locale and the messages itself, and render that from your root layout:
// app/providers.tsx
'use client'
import { type Messages, SnowUIProvider } from '@holakirr/snow-ui'
import type { ReactNode } from 'react'
import { ru } from 'react-day-picker/locale' // a date-fns locale plus day-picker labels
// Module scope: the same object on every render (see below).
const messages: Messages = {
/* every namespace: alert, alertDialog, avatarGroup, badge, breadcrumb,
calendar, charts, combobox, commandPalette, datePicker, dialog,
dropdownMenu, input, link, listCards, pagination, progress, search,
sheet, sidebar, slider, spinner, tag, textarea, toast */
}
export const Providers = ({ children }: { children: ReactNode }) => (
<SnowUIProvider messages={messages} locale={ru}>
{children}
</SnowUIProvider>
)// app/layout.tsx (a Server Component)
import { Providers } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}For several languages, pass the language (a string) from the layout and pick the locale and messages inside Providers: <Providers lang={lang}> with const { locale, messages } = translations[lang].
- Typed:
Messagesis grouped by component (messages.pagination.previous,messages.dialog.close…). Strings with values are functions, so a translation can order and inflect them:tag: { remove: (label) => \Удалить тег ${label}` },calendar: { previousYears: (count) => … }`. - Partial:
messagestakes any subset (MessagesOverrides) and merges it over the defaults, namespace by namespace; a message set toundefinedkeeps the default. Providers nest: an inner one overrides the outer one for its subtree. - Stable: define
messagesat module scope (oruseMemoit). The provider recognises an equal inline object, but inline function messages are new functions on every render, which re-renders every component that reads the messages. - Component props win:
closeLabel(DialogHeader,SheetContent),clearLabelandplaceholder(Search),clearLabel/statusLabel(Input),removeLabel(Tag),externalLabel(Link),label(BreadcrumbEllipsis,PaginationEllipsis),label/placeholder/emptyMessage/loadingLabel(CommandPalette),label/placeholder/emptyMessageinsearch(DropdownMenuContent),emptyMessage/loadingLabel/clearLabel/createLabel(Combobox,MultiSelect),placeholder/clearLabel(DatePicker,DateRangePicker),thumbLabels/valueFormatter(Slider),todayLabel/lastSelectionLabel/labels(Calendar),statusLabel/dismissLabel(Alert), the children orlabelofAlertDialogCancel,getValueLabel(Progress,ProgressCircle),label(Spinner),title(NotificationsCard,ActivitiesCard,ContactsCard) andaria-label(Breadcrumb,Pagination, the previous / next links,ToastClose,SidebarTrigger,Badge,Progress,ProgressCircle) override the provider. - Dates:
localegoes toCalendar(month and weekday names, day labels; its ownlocaleprop wins), toDatePickerandDateRangePicker(the same, and the dates they show) and toScheduler's day, time and cell labels (locale.code;en-USwithout a locale, so the server and the browser render the same text). A react-day-picker locale (react-day-picker/locale) also translates the day picker's own labels (day buttons, dropdowns…). The week starts on Monday in all of them;weekStartsOnsets another day, or"locale"for the locale's first day (the default in 6.0), and their ownweekStartsOn/startOfWeekwins. - Calendar navigation:
messages.calendar.previousMonth,nextMonthandnavigationname the previous / next month buttons and their landmark, so they translate with a plain date-fns locale too. Their English defaults give way to a react-day-picker locale's own labels; a translated message wins over them, andCalendar'slabelsprop over both. - Your components:
useMessages()returns the current messages,useSnowUI()the messages,localeanddir.
The package ships English only: translations belong to the app. Storybook's "Locale" toolbar switches every story to an example Russian set (.storybook/locales.ts), a starting point for your own.
RTL
For right-to-left languages, set the direction on the document and on the provider, in the client Providers module from Localization:
// app/providers.tsx
'use client'
import { SnowUIProvider } from '@holakirr/snow-ui'
import type { ReactNode } from 'react'
import { ar } from 'react-day-picker/locale'
import { arMessages } from './messages'
export const Providers = ({ children }: { children: ReactNode }) => (
<SnowUIProvider dir="rtl" locale={ar} messages={arMessages}>
{children}
</SnowUIProvider>
)// app/layout.tsx (a Server Component): only strings cross into the client.
<html lang="ar" dir="rtl">
<body>
<Providers>{children}</Providers>
</body>
</html>- Layout follows the DOM
dir: the components use logical utilities (ps-/pe-,ms-/me-,start-/end-,text-start,rounded-s-/rounded-e-,border-s/border-e), so paddings, icons, check marks, badges, the remove button of aTagand the rounded ends of aCalendarrange swap sides.diron any element scopes it to a subtree, likedata-theme. - Directional icons point the other way: pagination and calendar arrows, the
Accordion, menu and submenu arrows, theLinkarrow and external icon,SidebarTriggerand icon separators ofBreadcrumbSeparatorgetrtl:-scale-x-100(for your own icons: the same class, or the icons'mirroredprop).Switchmoves its thumb the other way. - Keyboard follows the provider's
dir(wrapped RadixDirectionProvider): inTabs,RadioGroup,ToggleGroup,Sliderand horizontalAccordionArrowLeft moves forward, menus open submenus to the left with ArrowLeft, andCalendarpassesdirto react-day-picker's arrow keys. - Portals (
Dialog,AlertDialog,Sheet,Popover,DropdownMenu,ContextMenu,Tooltip,Select,CommandPalette, the lists ofComboboxandMultiSelect, the calendars ofDatePickerandDateRangePicker, the toast viewport) render outside yourdirscope, so they takedirfrom the provider. - Sides:
SheetandSidebartakeside="start"/"end"(defaults:Sheetend,Sidebarstart), resolved with the provider'sdir;left/rightstay physical.Tag'sarrow-start/arrow-endshapes flip,arrow-left/arrow-rightdon't.Typography'saligntakesstart(the default) andend. In right-to-left text toasts are swiped away to the left.
Storybook's "Direction" toolbar renders any story right-to-left; the RTL stories of Button, Input, Tabs, Breadcrumb, Pagination, Sidebar, Dialog, AlertDialog, Alert, Progress, DropdownMenu, Calendar, Tag and Toaster pin it and check the layout and keyboard in their interaction tests.
Accessibility deviations from the Figma kit
This is a reference implementation, so where the Figma kit conflicts with WCAG 2.2 AA, accessibility wins. Contrast ratios are against background-1 (#FFF light, #333 dark) unless stated otherwise.
| Where | Figma | Library | Why |
| --- | --- | --- | --- |
| Focus indicator (all controls except text fields) | "Focus" effect: 4px ring, black 4% (1.1:1); no focus state on Button or Tab | focus-ring: the Figma 4px ring in black-80 — 12.6:1 light, 8.7:1 dark | 2.4.7, 1.4.11 |
| Secondary text: Breadcrumb parents, Table headers and result count (TableResults), Calendar weekdays and outside days, Dialog / Sheet / Form descriptions, Label (and the Input title), ListItem descriptions, Sidebar group labels, menu group labels, shortcuts and submenu value hints (DropdownMenu, ContextMenu, Select), CommandPalette headings and empty message, Scheduler day and hour labels, the Pagination ellipsis | Black/40%: 2.85:1 on white, 3.41:1 on #333 | text-secondary: Black/60% in light mode (5.74:1 on background-1, at least 5.5:1 on background-2, the Black/4% hover and the Color 1/2 tints), White/70% in dark mode (7.08:1 on #333, at least 4.76:1 on the lightest popover surface) | 1.4.3 |
| Inactive tabs (every TabsList variant), off Toggle / ToggleGroup items, Bare buttons | 40% layer opacity: the label is 2.85:1 | the label and icon are text-secondary (5.74:1 light, 7.08:1 dark), black on hover and keyboard focus (primary for the active Underline tab); disabled items keep Black/20% | 1.4.3, 1.4.11 |
| The Tag close icon | 40% layer opacity: 2.85:1 | 80%: at least 3.46:1 on every tag, the active indigo one included (60% would be 2.43:1 there); 100% on hover and keyboard focus | 1.4.11 |
| The Select and Combobox chevrons, the TablePageSize arrow; the submenu chevrons of DropdownMenu and ContextMenu | Black/40% (2.85:1); Black/20% on the submenus (1.6:1) | text-secondary: at least 5.5:1 in light mode, 4.82:1 in dark mode (on the highlighted item) | 1.4.11 |
| Select placeholder, the Search shortcut hint, the CommandPalette Enter hint, the Textarea counter | Black/20%: 1.6:1 | text-secondary. The Select placeholder is the trigger's visible text, not a native placeholder; the Search hint has no fill of its own (a second Black/4% layer on the hovered dark field took it under 4.5:1) | 1.4.3 |
| Slider value (showValue) | Black/20% on the track (1.6:1; Black/40% when active), White/40% on the fill (3.66:1) | text-secondary on the track (black-80 when active) and the per-mode white at 70% on the fill (white-80 when active): at least 4.82:1 (5.5:1 in light mode) and 5.84:1 (9.95:1 in light mode) | 1.4.3 |
| Slider label and handle line on the fill | Static White: 2.07:1 on the dark-mode fill (Primary is indigo there) | the per-mode white: white on the black fill (21:1), black on the indigo one (10.15:1) | 1.4.3, 1.4.11 |
| Error text: FormMessage | Secondary/Red #FF4747: 3.36:1 on white, 3.76:1 on #333 | red-text: #D42020 in light mode, #FF8080 in dark mode (5.21:1 on background-1 in both) | 1.4.3 |
| Destructive menu items (variant="destructive" on DropdownMenuItem, ContextMenuItem) | red text and icon | red-text (#D42020: 5.21:1 on the popover, 4.77:1 on the highlight); in dark mode mixed with 40% white (#FFB3B3: 6.21:1, 4.59:1), as #FF8080 is 3.22:1 on the White/10% highlight | 1.4.3 |
| CommandPalette match (highlightMatches) | Secondary/Indigo text: 2.07:1 on white | indigo-text (#5B5BD6: 5.37:1 on the palette, 4.91:1 on the highlighted option); in dark mode #ADADFB mixed with 30% white (6.47:1, 4.78:1), as #ADADFB is 3.78:1 on the White/10% highlight | 1.4.3 |
| TooltipShortcut | 40% opacity: 2.8:1 | 70%: at least 5.5:1 on both tooltip variants in both modes | 1.4.3 |
| Search clear button | 40% opacity: 2.85:1 | 60% (5.74:1) | 1.4.11 |
| Filled Button label, the active filled tab and the pressed filled toggle (5.2), checked Checkbox mark | #FFF in both modes: 2.07:1 on the dark-mode indigo Primary | the per-mode white token: white on black (21:1), black on indigo (10.15:1) | 1.4.3, 1.4.11 |
| Badge number | #FFF on indigo, 2.07:1 | black on indigo, 10.15:1 | 1.4.3 |
| Red Badge number (color="red", 5.2) | #FFF on Secondary/Red, 3.36:1 | the per-mode white on red-text: white on #D42020 (5.21:1), black on #FF8080 in dark mode (8.65:1); the red dot keeps Secondary/Red (3.36:1 on white) | 1.4.3 |
| Indigo text: default Link, active Tag | Secondary/Indigo #ADADFB: 2.07:1 on white | indigo-text: #5B5BD6 in light mode (5.37:1; 5.04:1 on the active Tag tint), #ADADFB in dark mode (6.11:1). Fills keep the Figma indigo | 1.4.3 |
| External Link | — | target="_blank" and rel="noopener noreferrer" by default, and a visually hidden "(opens in a new tab)" (externalLabel) | 3.2.5 (advisory) |
| Tag close button | a 12px icon | the same icon with a 24×24px hit area | 2.5.8 |
| Switch (28×16px), bare Buttons, the Slider bar's thumb (the 2×8px handle), sortable Table headers (16px high), TableCopyButton (16px), small line tabs (22px), the Search clear button (16px) | their Figma size | the same look with an invisible hit area of at least 24×24px (hit-area) | 2.5.8 |
| Icon-only tabs, buttons and toggles | — | a development warning without aria-label / aria-labelledby | 4.1.2 |
| Invalid form fields | text fields: a Secondary/Red stroke, a 16px Warning icon at the end of the field and the message under it; the title stays grey | while a field has aria-invalid (FormControl sets it): a 1px Secondary/Red (red) stroke on Input, InputSmall, Textarea, Search, the Select trigger and Switch, a red ring on Checkbox and Radio, a red stroke inside the Slider bar and red thumb borders on a Slider range (3.36:1 on white, 3.76:1 on #333; the control-border-invalid token, red-text with more contrast); the error text is FormMessage. Input and Textarea also show the kit's 16px Warning icon at the end in Secondary/Red (3.36:1 on a light field, 3.32:1 on a dark one on the page and 2.78:1 on a dark one in a dialog or popover, like the stroke; with more contrast the Input's icon follows the stroke to red-text, the Textarea's stays Secondary/Red), and the title and FormLabel stay grey | 1.4.1, 1.4.11 |
| Dark Tooltip in dark mode | Black/80% flips to white/80% but the text stays #FFF | the text flips with it (black on white/80%) | 1.4.3 |
The colour of those inactive items and Bare buttons is a custom property (--segment-fg for segmented items and Toggle, --tab-fg for Underline tabs, --button-fg for Bare buttons) that hover, focus and the selected state change. A text-* class passed as className sets the colour in every state; to change only the rest colour, set the property: className="[--button-fg:var(--color-red-text)]". The state colours don't depend on :enabled, so a TabsTrigger or Toggle rendered as a link (asChild) gets them too.
Text fields (Input, InputSmall, Textarea, Search, and the Combobox and MultiSelect fields) keep the Figma "Focus" state: a Black/40% stroke and the 4px Focus ring on any focus (mouse or keyboard); the caret and the stroke mark focus, so they don't get the focus-ring colour. With more contrast the focus stroke is 2px.
Data, overlay and navigation components:
- Calendar: today's date is static black on Secondary/Indigo (10.15:1). Figma uses white, which is 2.07:1 (1.4.3). In dark mode a selected day is indigo too, so there today has no fill: indigo text (white on hover). Today is announced with
aria-current; a dot appears only with forced colours, where fills disappear. The same goes for the current month, year and time in the date pickers' views. Outside days (the previous and next month's dates in the grid) are real, selectable dates, not decoration, so they get the full 4.5:1:text-secondaryinstead of Black/40%. - Scheduler: today's day label is static black on Secondary/Indigo (was white, 2.06:1) with
aria-current="date", so today isn't told by colour alone (1.4.1); event times are 60% static black on Color 2 (5.5:1) instead of 40% (1.4.3). The week is a WAI-ARIA grid with one tab stop and arrow keys (see its page). - Sidebar: group labels (
SidebarGroupLabel) aretext-secondary(5.74:1 light, 7.08:1 dark on Background/1; at least 5.65:1 / 6.42:1 on the inset variant's Background/2). Figma's Black/40% is 2.85:1 at 14px (1.4.3). - Chip: the Figma text is the Secondary colour itself (1.5–2.4:1 on white). The text mixes it with 45% of
black(white in dark mode): at least 4.63:1 on the tints and 5.13:1 on white, 5.66:1 in dark mode; grey text istext-secondary(1.4.3). The dots and the tints keep the Figma colours. - TextStrip: Figma's white text on the Secondary/Indigo strip is 2.07:1; it is static black (10.15:1), as on the Badge (1.4.3). With more contrast the Black/4% pill gets the
control-borderstroke and the strip a 2pxblack-80ring (1.4.11). - Alert (a library extension): the text is black and
text-secondaryon every status tint (at least 5.2:1). The status icons mix the Secondary colours with 40% ofblack(white in dark mode): at least 3.6:1 on the light tints and 5:1 on the dark ones, where Secondary/Green, Yellow and Blue alone are 1.5–2:1 on white (1.4.11). The status is also read out as text, not only shown by colour and icon (1.4.1). - AlertDialog (a library extension): the destructive action is
red-text(#D42020, #FF8080 in dark mode) with the per-modewhitelabel, 5.21:1 and 8.65:1; Secondary/Red under a white label would be 3.36:1 (1.4.3). - Combobox, MultiSelect: the kit has no combobox; they are built from the Input field and the Popover (the Select menu) and follow the rows above: group titles and the empty message in
text-secondary, the clear button at 60% opacity. They are text fields, so they keep the Figma "Focus" state instead of thefocus-ringcolour. - DatePicker, DateRangePicker: the kit's DatePicker is the calendar (
Calendar); the field is built from the Input field like the Select trigger, so its placeholder istext-secondary(the trigger's visible text), it gets thefocus-ringcolour, and its clear button is at 60% opacity. In the popover, the top area's separators and the date not being picked in a range aretext-secondary(Figma: Black/20%, 1.6:1; 1.4.3), and its parts keep the kit’s intrinsic text widths with invisible pointer hit areas (2.5.8). - Toast: a toast with an
actionstays until it is dismissed (itsdurationdefaults toNumber.POSITIVE_INFINITY, not the Toaster's 3 seconds, unless you set one), so the action doesn't vanish on a timer (2.2.1). Toasts with anactionor an infinitedurationget a close button by default (closable), so they can be dismissed with a pointer. The Figma toast has no close button. - Scrollbar (
scrollbar-snow): the kit hides the bar until the pointer is over the scroll area; the utility follows that state and also reveals the thumb while the scroll area contains keyboard focus. The kit also overlays the bar 4px from the edge, which CSS can't do: the WebKit scrollbar pseudo-elements take an 8px gutter.
Known gaps with the default (standard) contrast, which keeps the Figma values: the Black/20% rings of unchecked Checkbox and Radio and the 0.5px Black/20% Input stroke (1.6:1), the Switch's Black/20% off track (1.6:1) and white thumb on the dark-mode indigo track (2.07:1), the Black/20% placeholders of the native text fields (Input, InputSmall, Textarea, Search: axe doesn't check ::placeholder, and they are never the field's only label), the Black/4% fill of the gray InputSmall and Search fields (1.1:1), the Black/4% Slider bar and range track (1.09:1) and the range's static white thumbs on white (1:1), the Secondary/Red invalid stroke on the gray fields and the dark field fill (2.32–2.94:1), the Black/4% highlight of menu items, and the text fields' focus indicator: the Figma 0.5px Black/40% stroke (2.85:1 on the fill; the caret and the 4px Black/4% Focus ring are the rest of it). The Link arrow and external icon are at 40% opacity (2.85:1). The Done check of an Input with status="success" is Secondary/Green (1.69:1). With more contrast (prefers-contrast: more or data-contrast="more", see Contrast) they all meet WCAG AA; the Link icons are then fully opaque, and the Done check is Secondary/Green mixed with 40% of black (4.4:1).
Storybook demos follow the same rules: the Table and Dashboard status cells colour only the dot (the Secondary colours are 1.7–2.4:1 as text), and avatar initials on Secondary colours are static black, not white.
Every story is checked with axe in the light and the dark theme, after its interaction test, and a violation fails CI (see the repository's CONTRIBUTING.md).
Component Documentation
Components are documented in Storybook with examples and props documentation. Visit the Storybook to explore the components and their usage.
Testing
- Storybook stories are tests too: every story is rendered in Chromium in both themes and once more with the OS asking for more contrast and reduced motion, checked with axe and for 24×24px target sizes, and many have
playinteraction tests (keyboard, focus, selection) - Unit tests are written using Vitest and React Testing Library
- Visual regression tests screenshot every story in both themes with Playwright, in Docker, and compare the shots with the committed Linux baselines (
bun run visual/bun run visual:updatefrom the repository root; see CONTRIBUTING.md) - Unit tests cover most components (see the
*.test.tsxfiles next to them), the toast store, the date utils and the design tokens; more are welcome
Usage
bun add @holakirr/snow-uiimport { Button } from '@holakirr/snow-ui'
function App() {
return (
<Button variant="filled" size="md">
Click me
</Button>
)
}Development
This package lives in the snow-ui monorepo under packages/ui. Install dependencies and run scripts from the repository root — see the root README. Icons come from the sibling workspace package @holakirr/snow-ui-icons.
License
MIT
