@andreyfedkovich/cozy-ui
v0.10.1
Published
Cozy UI — a premium, opinionated React component library for crafted product UIs. Typed, themeable, SSR-safe, tree-shakeable.
Downloads
2,271
Maintainers
Readme
Cozy UI
A premium, opinionated React component library for crafted product UIs.
Typed end-to-end · SCSS-modules with design tokens · SSR-safe · Tree-shakeable ESM + CJS
npm i @andreyfedkovich/cozy-uiTable of contents
- Live demo
- Why Cozy UI
- Installation
- Quick start
- Design tokens
- Component API
- Layout & content —
BaseBlock,Card,CollapsableBlock,Collapse,Carousel,EmptyComponent,Spinner - Inputs & forms — field validation helpers,
Button,RadioGroupButton,Input,Textarea,Calendar,Checkbox,Select,DialogSelect,TreeDialogSelect,InputCaption,Label - Navigation —
Tabs,TabsRounded,Stepper - Overlays —
Popover,TooltipDark,TooltipLight - Utility —
Tag,CopyTextTrigger - Workflow —
ApprovalRoute,CommentFeed,DetailView,SideNav,SettingsView,Switch,ImageSegmented
- Layout & content —
- Hooks & helpers
- Validation recipes
- Icons
- TypeScript
- SSR & framework support
- Theming
- Accessibility
- Local development
- Publishing
- Contributing
- License
Live demo
Explore every component in the browser: https://cozy-ui-components.vercel.app
Why Cozy UI
- Premium defaults out of the box. Soft shadows, generous spacing, calm motion — no theming required to look polished.
- Tokens you can trust. Colors, radii, and surfaces are exported as both CSS custom properties and TypeScript constants.
- Typed end-to-end. Generics on
Select,DialogSelect,TreeDialogSelect,Carousel, andRadioGroupButton— your data, your types. - Headless where it matters. Dialogs and labels are powered by Radix primitives; positioning by
@floating-ui/react. - SSR-safe. Works in Next.js, TanStack Start, Remix, and any Vite SPA. Portals are guarded.
- Zero global CSS leakage. SCSS modules everywhere. One stylesheet to import, no surprises.
- Tree-shakeable. Ships ESM + CJS +
.d.ts. Pay only for what you import.
Installation
npm i @andreyfedkovich/cozy-ui
pnpm add @andreyfedkovich/cozy-ui
bun add @andreyfedkovich/cozy-ui
yarn add @andreyfedkovich/cozy-uiPeer dependencies: React ≥ 18 and react-dom ≥ 18 (React 19 supported).
Import the stylesheet once at your app root. Which file depends on your host setup — see Styling in host apps below.
import "@andreyfedkovich/cozy-ui/styles.css";Sizing uses CSS rem against the browser’s default root font size (commonly 16px). You do not need to set html { font-size: … } for components to match the library demo.
Styling in host apps
The library publishes three CSS entry points:
| Import | Contents |
|--------|----------|
| @andreyfedkovich/cozy-ui/styles.css | Full bundle (SCSS modules + Tailwind v4 utilities) |
| @andreyfedkovich/cozy-ui/styles.modules.css | SCSS modules only — safe alongside Tailwind v3 |
| @andreyfedkovich/cozy-ui/styles.tailwind.css | Tailwind v4 utilities and shadcn design tokens |
A. Host without Tailwind (greenfield, demo apps):
import "@andreyfedkovich/cozy-ui/styles.css";B. Host with Tailwind v3 + shadcn (e.g. existing product apps):
// App entry (main.tsx / _app.tsx) — NOT inside your @tailwind index.css
import "@andreyfedkovich/cozy-ui/styles.modules.css";Covers SCSS-native components: SettingsView, Switch, SideNav, Input, Select, Stepper, and most other “cozy-native” UI. The modules bundle does not include Tailwind v4 preflight and will not override your host’s Tailwind reset.
C. You also need Tailwind-based cozy components (Calendar, shadcn wrappers):
import "@andreyfedkovich/cozy-ui/styles.modules.css";
import "@andreyfedkovich/cozy-ui/styles.tailwind.css"; // intentional — may conflict with Tailwind v3Use this only when you need components that rely on prebuilt Tailwind utilities. Do not import styles.css or styles.tailwind.css in a Tailwind v3 host unless you accept the risk of duplicate preflight and utility conflicts.
Notes:
- Several public components combine SCSS with shadcn primitives —
Button,Label,Calendar,Popover, and dialog-based components (DialogSelect,ApprovalRoute, …). In scenario B your host shadcn setup may already style them; if classes or tokens differ, use scenario C. - shadcn CSS variables (
--primary,--background, …) live instyles.tailwind.css, not instyles.modules.css. Tailwind v3 + shadcn hosts usually already define these in:root. - Import cozy styles at your app entry, not inside your Tailwind
@tailwindCSS file.
Typography (host application)
Cozy UI does not ship font files and does not hardcode a brand typeface. Text components inherit the font from your app (font-family: inherit / var(--cozy-font-family, inherit)).
Recommended setup in production:
- Register your brand fonts with
@font-face(same as your existing navbar). - Set the app root font once — either on
body:
body {
font-family: "Navigo", "RobotoRegular", sans-serif;
}or via the optional library token in styles.css:
:root {
--cozy-font-family: "Navigo", "RobotoRegular", sans-serif;
}- Import
@andreyfedkovich/cozy-ui/styles.cssas usual. All components (SideNav,Input,Button, etc.) will use the same font.
If your navbar already loads Navigo globally on body, no extra step is required for Cozy UI.
The library demo uses the system / Inter stack from your app shell — that is expected and not a component bug.
Tailwind-powered components
Most Cozy UI styles come from SCSS modules bundled into styles.modules.css. Components built on shadcn use Tailwind class names in JS; those utilities are prebuilt into styles.tailwind.css (and included in the full styles.css bundle). You do not need Tailwind CSS in your application unless you choose scenario B above.
If your app already uses Tailwind v4 and you prefer to generate utilities yourself, you may add @source pointing at the library bundle — optional, not required.
If your app uses Tailwind v3, import styles.modules.css only (scenario B) or add styles.tailwind.css when needed (scenario C). Do not import the full styles.css bundle — it includes Tailwind v4 preflight that conflicts with v3 hosts.
Adding a new Tailwind-based component (library authors)
- Primitive in
src/components/ui/<name>.tsx(Tailwind + Radix as needed). - Public API in
src/lib/components/<Name>/(field label, errors, value; SCSS optional for the trigger shell). - Ensure paths are covered by
@sourceinsrc/lib/tailwind.css(../lib/**/*,../components/ui/**/*). - Run
npm run build:libbefore publish; verifydist-lib/styles.css(and split files) include the new classes.
Quick start
import { Button, Card, Tag } from "@andreyfedkovich/cozy-ui";
import "@andreyfedkovich/cozy-ui/styles.css";
export default function App() {
return (
<div style={{ display: "grid", gap: 16, padding: 24 }}>
<Card text="Welcome to Cozy UI" height={160} />
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Tag>New</Tag>
<Button variant="primary" onClick={() => console.log("hi")}>
Get started
</Button>
</div>
</div>
);
}Design tokens
Tokens ship two ways:
- CSS custom properties — applied globally by
styles.cssand consumable from any stylesheet. - TypeScript constants — re-exported from the package root, ideal for inline styles or chart libraries.
import { colors } from "@andreyfedkovich/cozy-ui";
const accent = colors.blue03; // typed string| Group | Tokens (excerpt) |
| ----------- | ------------------------------------------------- |
| Brand | blue01 … blue07 |
| Neutrals | gray01 … gray09, white, black |
| Status | green, red, yellow |
| Surfaces | surfacePrimary, surfaceMuted, surfaceRaised |
Override a token in your app's CSS:
:root {
--cozy-blue-03: #2563eb;
}Component API
Every snippet below is copy-paste runnable against the real exports.
Layout & content
BaseBlock
A titled section wrapper with optional subtitle. Use it as the building block of dashboards and forms.
| Prop | Type | Default | Description |
| ---------- | ----------------- | ------- | ------------------------------------ |
| id | string | — | Anchor id for in-page navigation. |
| title | ReactNode | — | Section title. |
| subtitle | ReactNode | — | Supporting copy under the title. |
| children | ReactNode | — | Section content. |
| className| string | — | Additional class on the root. |
import { BaseBlock } from "@andreyfedkovich/cozy-ui";
<BaseBlock title="Profile" subtitle="Public information visible to teammates">
{/* form content */}
</BaseBlock>;Card
A premium content tile with optional background image and link behavior.
| Prop | Type | Default | Description |
| ----------------- | ------------------- | ------- | ------------------------------------ |
| text | string | — | Title rendered inside the card. |
| width | number | — | Fixed width in px. |
| height | number | — | Fixed height in px. |
| backgroundColor | string | — | CSS color for the surface. |
| imageUrl | string | — | Background image URL. |
| textColor | string | — | Title color override. |
| link | string | — | If provided, renders as a <Link>. |
| className | string | — | Extra class. |
import { Card } from "@andreyfedkovich/cozy-ui";
<Card
text="Q4 highlights"
imageUrl="/covers/q4.jpg"
height={220}
link="/reports/q4"
/>;CollapsableBlock
A block with a header that expands and collapses its content.
import { CollapsableBlock } from "@andreyfedkovich/cozy-ui";
<CollapsableBlock
header="Advanced settings"
content={<div>{/* hidden by default */}</div>}
/>;Collapse
Animated expandable section with explicit header and content.
| Prop | Type | Default | Description |
| ------------- | ----------- | ------- | ---------------------------------------- |
| header | ReactNode | — | Header row content. |
| content | ReactNode | — | Expandable body content. |
| isOpen | boolean | — | Controlled open state. |
| defaultOpen | boolean | false | Initial open state (uncontrolled mode). |
| onToggle | () => void| — | Toggle callback. |
import { Collapse } from "@andreyfedkovich/cozy-ui";
import { useState } from "react";
const [open, setOpen] = useState(false);
<Collapse
header="Toggle"
content="Hidden content"
isOpen={open}
onToggle={() => setOpen((v) => !v)}
/>;Carousel
Generic, typed carousel with captions. Items must have an id.
import { Carousel } from "@andreyfedkovich/cozy-ui";
const slides = [
{ id: 1, src: "/a.jpg", caption: "Atlas" },
{ id: 2, src: "/b.jpg", caption: "Borealis" },
];
<Carousel
items={slides}
renderItem={(s) => <img src={s.src} alt={s.caption} />}
/>;EmptyComponent
Friendly empty state with illustration, title, and description.
import { EmptyComponent } from "@andreyfedkovich/cozy-ui";
<EmptyComponent title="Nothing here yet" description="Create your first item to get started." />;Spinner
Loading indicator with sizes extraSmall | small | medium | large.
import { Spinner } from "@andreyfedkovich/cozy-ui";
<Spinner size="medium" />;Inputs & forms
Button
| Prop | Type | Default | Description |
| --------- | ----------------------------------------------------------------------- | ----------- | ------------------------ |
| variant | "default" \| "primary" \| "secondary" \| "text" \| "link" \| "danger" | "default" | Visual style. |
| size | "small" \| "medium" \| "large" | "medium" | Control size. |
| loading | boolean | false | Shows inline spinner. |
| disabled| boolean | false | Disabled state. |
| ...rest | ButtonHTMLAttributes<HTMLButtonElement> | — | All native button props. |
import { Button } from "@andreyfedkovich/cozy-ui";
<Button variant="primary" size="large" loading>
Saving…
</Button>;RadioGroupButton
Segmented radio group, generic over its option value.
import { RadioGroupButton } from "@andreyfedkovich/cozy-ui";
import { useState } from "react";
const [view, setView] = useState<"grid" | "list">("grid");
<RadioGroupButton
value={view}
onChange={setView}
options={[
{ value: "grid", label: "Grid" },
{ value: "list", label: "List" },
]}
/>;Field validation (headless)
Form state stays in your app (React Hook Form, TanStack Form, or useState). Cozy UI provides a shared FieldMeta contract and showErrorPolicy so all fields resolve “when to show the error” the same way.
| Export | Description |
| ------ | ----------- |
| FieldMeta | touched, dirty, submitted, stepSubmitted, hasValue, invalid, errorMessage, validationPending, errorKind |
| ShowErrorPolicy | "default" (legacy) | "draftFriendly" | "wizardStep" | "onBlur" | "onSubmit" | custom |
| resolveShowError, resolveFieldError, resolveFieldMessage, resolveDisplayError | Pure functions (SSR-safe) |
| useFieldState, useFormFields, useValidationRequest | React hooks |
| attemptWizardStep, attemptFormSubmit | Validate-on-click helpers |
Recommended policy for draft-friendly forms: draftFriendly — no flash on first keystroke; saved invalid visible on load.
Legacy default policy: invalid && (touched || submitted || hasValue).
Props on fields: error, suppressError, fieldMeta, showErrorPolicy.
See Validation recipes for step-by-step recipes and expected form behavior.
Callback families:
| Family | Components | Value callback | Focus |
| ------ | ---------- | -------------- | ----- |
| Native text | Input, Textarea, Checkbox | onChange(event) — DOM | onBlur / onFocus via ...rest |
| Value picker | Select, DialogSelect, TreeDialogSelect, Calendar | onValueChange(value) (canonical); onChange deprecated alias | onBlur / onFocus on trigger |
import { Input, resolveFieldError } from "@andreyfedkovich/cozy-ui";
import { useState } from "react";
const [email, setEmail] = useState("");
const [touched, setTouched] = useState(false);
const [submitted, setSubmitted] = useState(false);
const meta = {
touched,
submitted,
hasValue: email.trim().length > 0,
invalid: !email.includes("@"),
errorMessage: "Enter a valid email.",
};
// Optional: resolve message before render
resolveFieldError(meta, "default");
<Input
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setTouched(true)}
fieldMeta={meta}
showErrorPolicy="default"
/>;React Hook Form (recipe): use register on Input; use Controller on Select with onValueChange={(opt) => field.onChange(opt)}.
Input
Accessible text field with optional label and validation message for product forms.
| Prop | Type | Default | Description |
| ---------------- | --------------------------------------- | ------- | ------------------------------------ |
| label | ReactNode | — | Field label above the input. |
| tooltipContent | ReactNode | — | Help tooltip on the «?» icon next to the label. |
| tooltipPopperClassName | string | — | Extra class for the tooltip popper. |
| error | string \| null | — | Validation message (overrides fieldMeta). |
| fieldMeta | FieldMeta | — | Form meta for policy-based error display. |
| showErrorPolicy | ShowErrorPolicy| "default" | When to show fieldMeta.errorMessage. |
| disabled | boolean | false | Disabled state. |
| className | string | — | Wrapper class. |
| inputClassName | string | — | Native <input> class. |
| ...rest | InputHTMLAttributes<HTMLInputElement> | — | All native input props. |
import { Input } from "@andreyfedkovich/cozy-ui";
import { useState } from "react";
const [email, setEmail] = useState("");
<Input
label="Email"
placeholder="[email protected]"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>;
<Input
label="Password"
type="password"
error="Minimum 8 characters."
/>;Textarea
Accessible multiline text field with optional label and validation message for product forms.
| Prop | Type | Default | Description |
| ------------------------ | ------------------------------------------ | ------- | ---------------------------------------- |
| label | ReactNode | — | Field label above the textarea. |
| tooltipContent | ReactNode | — | Help tooltip on the «?» icon next to the label. |
| tooltipPopperClassName | string | — | Extra class for the tooltip popper. |
| error | string \| null | — | Validation message under the textarea. |
| disabled | boolean | false | Disabled state. |
| className | string | — | Wrapper class. |
| textareaClassName | string | — | Native <textarea> class. |
| ...rest | TextareaHTMLAttributes<HTMLTextAreaElement> | — | All native textarea props (rows, placeholder, value, onChange, etc.). |
import { Textarea } from "@andreyfedkovich/cozy-ui";
import { useState } from "react";
const [comment, setComment] = useState("");
<Textarea
label="Comment"
placeholder="Write your message…"
rows={4}
value={comment}
onChange={(e) => setComment(e.target.value)}
/>;
<Textarea
label="Description"
error="Description is required."
/>;Calendar
Date picker field for forms. Value is stored as yyyy-MM-dd (or null); the trigger shows dd.MM.yyyy. Includes helpers for parsing and serializing local calendar days.
Styling: import @andreyfedkovich/cozy-ui/styles.css once (same as other components). Calendar popover and day grid use Tailwind utilities that are included in that file — no extra CSS or Tailwind config in your project.
| Prop | Type | Default | Description |
| ------------------------- | --------------------------------- | ------- | -------------------------------------------------------- |
| label | string | — | Field label. |
| required | boolean | false | Appends * to the label. |
| value | string \| null | — | Selected date as yyyy-MM-dd. |
| onValueChange | (value: string \| null) => void | — | Called when the user picks or clears a date. |
| onChange | (value: string \| null) => void | — | Deprecated. Use onValueChange. |
| onBlur / onFocus | focus handlers | — | Forwarded to the trigger button. |
| minDate | Date | — | Earliest selectable day (inclusive, local calendar). |
| error | string \| null | — | Validation message (overrides fieldMeta). |
| fieldMeta | FieldMeta | — | Form meta for policy-based error display. |
| showErrorPolicy | ShowErrorPolicy | "default" | When to show fieldMeta.errorMessage. |
| disabled | boolean | false | Disables the trigger. |
| tooltipContent | ReactNode | — | Help tooltip on the «i» icon next to the label. |
| tooltipPopperClassName | string | — | Extra class for the tooltip popper. |
| className | string | — | Wrapper class. |
Exported utilities: startOfLocalDay, todayLocalDay, toYmdString, parseYmdToLocalDay.
import {
Calendar,
todayLocalDay,
} from "@andreyfedkovich/cozy-ui";
import { useState } from "react";
const [startDate, setStartDate] = useState<string | null>(null);
<Calendar
label="Дата начала"
required
value={startDate}
onValueChange={setStartDate}
minDate={todayLocalDay()}
/>;
<Calendar
label="Дедлайн"
value={startDate}
onValueChange={setStartDate}
error="Укажите дату."
tooltipContent="Дата должна быть не раньше сегодня."
/>;Checkbox
Accessible checkbox with a custom premium box, optional inline label, and validation message.
| Prop | Type | Default | Description |
| ------------------- | -------------------------------------------- | ------- | ---------------------------------------- |
| label | ReactNode | — | Label text to the right of the checkbox. |
| error | string \| null | — | Validation message under the control. |
| disabled | boolean | false | Disabled state. |
| className | string | — | Wrapper class. |
| checkboxClassName | string | — | Class on the visual checkbox box. |
| ...rest | Omit<InputHTMLAttributes<HTMLInputElement>, "type"> | — | All native checkbox props (checked, onChange, name, etc.). |
import { Checkbox } from "@andreyfedkovich/cozy-ui";
import { useState } from "react";
const [agreed, setAgreed] = useState(false);
<Checkbox
label="Согласен с условиями обработки данных"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>;
<Checkbox label="Уведомления по email" defaultChecked disabled />;
<Checkbox
label="Обязательное согласие"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
error={agreed ? null : "Необходимо принять условия."}
/>;Select
Powerful, virtualized-friendly select with single and multiple modes, search, custom rendering, and table layout.
| Prop | Type | Default | Description |
| ------------- | ------------------------------------- | ---------- | ------------------------------------ |
| mode | "single" \| "multiple" | — | Selection mode. |
| value | CustomOption \| CustomOption[] | — | Current value. |
| options | CustomOption[] | — | Available options. |
| onValueChange | (option) => void | — | Selection callback (canonical). |
| onChange | (option) => void | — | Deprecated. Use onValueChange. |
| onBlur / onFocus | focus handlers on trigger | — | For touched tracking. |
| fieldMeta / showErrorPolicy | see Field validation | — | Policy-based error display. |
| onSearch | (value: string) => void | — | Async search hook. |
| template | "list" \| "table" | "list" | Dropdown layout. |
| columns | SelectColumn[] | — | Required when template="table". |
| isLoading | boolean | false | Show loading state in dropdown. |
| error | string \| null | — | Validation message. |
| label | ReactNode | — | Field label. |
| tooltipContent | ReactNode | — | Help tooltip on the «?» icon next to the label. |
| tooltipPopperClassName | string | — | Extra class for the tooltip popper. |
import { Select, type CustomOption } from "@andreyfedkovich/cozy-ui";
import { useState } from "react";
const options: CustomOption<unknown, string>[] = [
{ value: "design", label: "Design" },
{ value: "engineering", label: "Engineering" },
];
const [value, setValue] = useState<CustomOption<unknown, string> | null>(null);
<Select
mode="single"
label="Department"
placeholder="Pick one"
value={value}
options={options}
onValueChange={setValue}
/>;DialogSelect
Dialog-based picker for large datasets — search + paginated loading + multi-select.
| Prop | Type | Description |
| ------------------------ | ----------- | ----------------------------------------------- |
| label | ReactNode | Field label above the trigger. |
| tooltipContent | ReactNode | Help tooltip on the «?» icon next to the label. |
| tooltipPopperClassName | string | Extra class for the tooltip popper. |
import { DialogSelect } from "@andreyfedkovich/cozy-ui";
<DialogSelect
title="Add reviewer"
placeholder="Choose a person"
loadOptions={async ({ search, page, pageSize }) => {
const res = await fetch(`/api/people?q=${search}&page=${page}&size=${pageSize}`);
const { items, total } = await res.json();
return { options: items.map((p) => ({ value: p.id, label: p.name })), total };
}}
onValueChange={(opt) => console.log(opt)}
/>;TreeDialogSelect
Hierarchical picker with lazy-loaded branches and search.
| Prop | Type | Description |
| ------------------------ | ----------- | ----------------------------------------------- |
| label | ReactNode | Field label above the trigger. |
| tooltipContent | ReactNode | Help tooltip on the «?» icon next to the label. |
| tooltipPopperClassName | string | Extra class for the tooltip popper. |
import { TreeDialogSelect } from "@andreyfedkovich/cozy-ui";
<TreeDialogSelect
title="Pick a department"
placeholder="Choose department"
loadNodes={async ({ parentId, search }) => ({
nodes: await fetchChildren(parentId, search),
})}
searchNodes={async (search) => ({ matches: await searchTreeWithPath(search) })}
leafConfirmOnly
onValueChange={(node) => console.log(node)}
/>;With leafConfirmOnly, the confirm button in the dialog stays disabled until a row is selected and that node’s hasChildren is not strictly true (only leaves can be confirmed). Omit the prop to allow confirming any selected node, including branches.
InputCaption
Small caption row under an input — supports neutral, error, and success tones.
import { InputCaption } from "@andreyfedkovich/cozy-ui";
<InputCaption variant="error">Email is required.</InputCaption>;Label
Accessible label, pairs with any input via htmlFor.
import { Label } from "@andreyfedkovich/cozy-ui";
<Label htmlFor="email">Email</Label>;Navigation
Tabs
Classic underlined tabs.
import { Tabs } from "@andreyfedkovich/cozy-ui";
import { useState } from "react";
const [tab, setTab] = useState("overview");
<Tabs
value={tab}
onValueChange={setTab}
items={[
{ value: "overview", label: "Overview" },
{ value: "activity", label: "Activity" },
]}
/>;TabsRounded
Pill-shaped variant — great for filter bars.
import { TabsRounded } from "@andreyfedkovich/cozy-ui";
<TabsRounded
value="all"
onValueChange={(v) => console.log(v)}
items={[
{ value: "all", label: "All" },
{ value: "open", label: "Open" },
{ value: "closed", label: "Closed" },
]}
/>;Stepper
Linear, numbered progress for multi-step flows.
| Prop | Type | Default | Description |
| --------- | ---------------- | ------- | --------------------------------------------- |
| items | StepperItem[] | — | Step definitions. |
| current | number | 0 | Index of the active step. |
| onChange | (index) => void | — | Optional click handler for step buttons. |
import { Stepper } from "@andreyfedkovich/cozy-ui";
<Stepper
current={1}
onChange={(index) => console.log(index)}
items={[
{ label: "Account" },
{ label: "Profile" },
{ label: "Review" },
]}
/>;Overlays
Popover
Floating panel anchored to a trigger element. Positioning powered by @floating-ui/react.
import { Popover, Button } from "@andreyfedkovich/cozy-ui";
<Popover trigger={<Button>Open</Button>} placement="bottom-start">
<div style={{ padding: 12 }}>Anchored content</div>
</Popover>;TooltipDark / TooltipLight
Two tonal variants of the same tooltip primitive.
| Prop | Type | Default | Description |
| ----------- | ----------------------------------- | ----------- | -------------------------- |
| content | ReactNode | — | Tooltip body. |
| placement | TooltipPlacement | "top" | Floating placement. |
| trigger | "hover" \| "click" | "hover" | Activation trigger. |
| children | ReactNode | — | The anchor element. |
import { TooltipDark } from "@andreyfedkovich/cozy-ui";
<TooltipDark content="Copy to clipboard" placement="top">
<button aria-label="copy">⧉</button>
</TooltipDark>;Utility
Tag
Compact label for status, categories, counts.
import { Tag } from "@andreyfedkovich/cozy-ui";
<Tag isSmall onClick={() => {}}>Beta</Tag>;CopyTextTrigger
Wraps any element to copy a string to clipboard, with built-in feedback.
import { CopyTextTrigger } from "@andreyfedkovich/cozy-ui";
import { useState } from "react";
const [copied, setCopied] = useState(false);
<CopyTextTrigger
copied={copied}
onClick={() => {
navigator.clipboard.writeText("cozy-ui");
setCopied(true);
setTimeout(() => setCopied(false), 1000);
}}
>
<button>Copy package name</button>
</CopyTextTrigger>;Workflow
ApprovalRoute
The flagship workflow component. Renders a premium vertical timeline of levels → stages → approvers with statuses, rejection reasons, current-level highlight, empty-approver hints, and an optional editing mode.
| Prop | Type | Default | Description |
| ----------------- | ------------------------------------------------- | ------- | -------------------------------------------------------- |
| levels | ApprovalLevel[] | — | Sequential levels; each contains parallel stages. |
| editable | boolean | false | Enables add/remove controls. |
| title | string | — | Header title. |
| eyebrow | string | — | Small label above the title. |
| loadApprovers | (params) => Promise<{ options, total? }> | — | Async source for the "add approver" dialog. |
| onAddLevel | (name) => void | — | Edit callback. |
| onRemoveLevel | (levelId) => void | — | Edit callback. |
| onAddStage | (levelId, name) => void | — | Edit callback. |
| onRemoveStage | (levelId, stageId) => void | — | Edit callback. |
| onAddApprover | (levelId, stageId, person) => void | — | Edit callback. |
| onRemoveApprover| (levelId, stageId, approverId) => void | — | Edit callback. |
View mode — covers the three approver states (rejected, current, pending):
import { ApprovalRoute, type ApprovalLevel } from "@andreyfedkovich/cozy-ui";
const levels: ApprovalLevel[] = [
{
id: "l1",
name: "Manager review",
status: "completed",
stages: [{
id: "s1", name: "Direct manager",
approvers: [{ id: "u1", fullName: "A. Ivanova", status: "approved", actedAt: "2026-04-28" }],
}],
},
{
id: "l2",
name: "Finance",
status: "current",
stages: [
{ id: "s2", name: "Budget owner",
approvers: [{ id: "u2", fullName: "M. Petrov", status: "pending" }] },
{ id: "s3", name: "Controller",
approvers: [{ id: "u3", fullName: "S. Orlov", status: "rejected", actedAt: "2026-05-01", rejectReason: "Out of budget" }] },
],
},
{
id: "l3", name: "Director sign-off", status: "pending",
stages: [{ id: "s4", name: "Director", approvers: [] }], // empty → "approver not assigned"
},
];
<ApprovalRoute title="Purchase request #4821" eyebrow="Approval" levels={levels} />;Edit mode:
<ApprovalRoute
title="Route editor"
editable
levels={levels}
loadApprovers={async ({ search, page, pageSize }) => {
const res = await fetch(`/api/people?q=${search}&page=${page}&size=${pageSize}`);
const { items, total } = await res.json();
return { options: items.map((p) => ({ value: p.id, label: p.fullName })), total };
}}
onAddLevel={(name) => /* ... */ undefined}
onAddStage={(levelId, name) => /* ... */ undefined}
onAddApprover={(levelId, stageId, person) => /* ... */ undefined}
onRemoveApprover={(levelId, stageId, approverId) => /* ... */ undefined}
/>;SideNav
Premium vertical navigation panel with a user block on top, configurable sections, optional collapse and three visual variants — classic (light, reference-style panel), transparent (seamless, no panel chrome, compact rows — feels like part of the page) and aurora (deep gradient with glass and glow accents). Composition-first like DetailView.
| Prop | Type | Default | Description |
| ------------------- | ------------------------------------- | ----------- | -------------------------------------------------------------------- |
| user | SideNavUser | — | Built-in user block (avatar + name + role + optional status badge). |
| userSlot | ReactNode | — | Fully custom header content (overrides user). |
| sections | SideNavSection[] | — | Declarative sections, each with title and items. |
| children | ReactNode | — | Composition: SideNav.Section, SideNav.Item, SideNav.Divider, SideNav.Custom. |
| variant | "classic" \| "transparent" \| "aurora" | "classic" | Visual style switch. |
| activeId / defaultActiveId | string | — | Controlled / uncontrolled active item. |
| onActiveChange | (id: string) => void | — | Fires when an item is selected. |
| collapsible | boolean | false | Shows a collapse toggle in the header. |
| collapsed / defaultCollapsed | boolean | false | Controlled / uncontrolled collapsed state. |
| width / collapsedWidth | number \| string | 280 / 76 | Panel width in expanded / collapsed mode. |
| footer | ReactNode | — | Bottom slot (e.g. "Sign out"). |
Each SideNavItem supports icon, href, badge, active, disabled, onClick and nested children (renders as a collapsible submenu).
Declarative usage:
import { SideNav, HomeIcon, ProfileIcon, ClockIcon } from "@andreyfedkovich/cozy-ui";
<SideNav
variant="aurora"
collapsible
user={{ name: "Kate Petrova", role: "Head of Operations", badge: true }}
sections={[
{ items: [{ id: "home", label: "Home", icon: <HomeIcon /> }] },
{
title: "For me",
items: [
{ id: "profile", label: "My profile", icon: <ProfileIcon /> },
{ id: "time", label: "Working time", icon: <ClockIcon />, badge: "3" },
],
},
]}
onActiveChange={(id) => console.log(id)}
/>;Composition-first (like DetailView):
<SideNav user={...} variant="classic">
<SideNav.Section title="For me">
<SideNav.Item id="profile" icon={<ProfileIcon />} label="My profile" />
<SideNav.Item id="time" icon={<ClockIcon />} label="Working time" badge="3" />
</SideNav.Section>
<SideNav.Divider />
<SideNav.Section title="Custom">
<SideNav.Custom>{/* any JSX */}</SideNav.Custom>
</SideNav.Section>
</SideNav>SettingsView
Composition-first layout for settings pages (like DetailView). Supports declarative sections or JSX via SettingsView.Section, SettingsView.Group, SettingsView.Item, and SettingsView.Divider. Two visual variants (classic / elevated), two densities (comfortable / compact), collapsible sections, left icon badges, row badges (e.g. New/Beta), link rows (href, external), danger styling, and render for full row customization.
| Prop | Type | Default | Description |
| ------------- | ----------------------------------------- | --------------- | -------------------------------------------------------- |
| sections | SettingsSection[] | — | Declarative sections with items and optional groups. |
| children | ReactNode | — | Composition API subcomponents. |
| variant | "classic" \| "elevated" | "classic" | Visual style. |
| density | "comfortable" \| "compact" | "comfortable" | Row spacing. |
| layout | "card" \| "plain" | "card" | Section wrapper style. |
| loading | boolean | — | Shows a spinner instead of content. |
| emptyState | ReactNode | — | Custom empty state when there is no content. |
Each SettingsItem (in sections or <SettingsView.Item />) supports icon, label, description, control, badge, hint, href, external, danger, disabled, onClick, and render for full row customization.
import { SettingsView, Switch } from "@andreyfedkovich/cozy-ui";
<SettingsView
variant="classic"
density="comfortable"
sections={[
{
id: "general",
title: "General",
items: [
{
id: "notifications",
label: "Notifications",
description: "Email and push alerts",
control: <Switch defaultChecked ariaLabel="Notifications" />,
},
{
id: "import",
label: "Import from VS Code",
description: "Settings, extensions, and keybindings",
badge: "New",
},
{
id: "docs",
label: "Documentation",
href: "https://example.com/docs",
external: true,
},
{
id: "delete",
label: "Delete account",
description: "Permanently remove your data",
danger: true,
onClick: () => confirm("Delete account?"),
},
],
},
]}
/>;Composition-first:
<SettingsView variant="elevated">
<SettingsView.Section title="Privacy" collapsible defaultOpen>
<SettingsView.Item label="Analytics" control={<Switch ariaLabel="Analytics" />} />
<SettingsView.Divider />
<SettingsView.Group title="Advanced">
<SettingsView.Item label="Debug mode" control={<Switch size="sm" ariaLabel="Debug" />} />
</SettingsView.Group>
</SettingsView.Section>
</SettingsView>Switch
iOS-style toggle with a green track by default, white thumb and shadow. Controlled or uncontrolled; sizes sm / md; optional blue accent.
| Prop | Type | Default | Description |
| ------------------------ | ------------------------- | --------- | -------------------------------------------------------- |
| checked | boolean | — | Controlled value. |
| defaultChecked | boolean | — | Initial value (uncontrolled). |
| onChange | (next: boolean) => void | — | Fires when toggled. |
| size | "sm" \| "md" | "md" | Track and thumb size. |
| color | "green" \| "blue" | "green" | Active track color. |
| disabled | boolean | — | Disables interaction. |
| label | ReactNode | — | Label text to the right of the switch. |
| tooltipContent | ReactNode | — | Help tooltip on the «?» icon next to the label. |
| tooltipPopperClassName | string | — | Extra class for the tooltip popper. |
| ariaLabel | string | — | Accessible name when label is not set. |
| className | string | — | Class on the button, or on the wrapper when label set. |
import { useState } from "react";
import { Switch } from "@andreyfedkovich/cozy-ui";
// Uncontrolled
<Switch defaultChecked ariaLabel="Dark mode" onChange={(v) => console.log(v)} />
// Controlled
const [on, setOn] = useState(true);
<Switch checked={on} onChange={setOn} size="sm" color="blue" ariaLabel="Sync" />
// With inline label and tooltip (e.g. standalone form field)
<Switch
label="Уведомления по email"
defaultChecked
tooltipContent="Письма о важных событиях в аккаунте."
/>ImageSegmented
Premium segmented control with image previews (Agent/Editor-style). Each option accepts any ReactNode in image — SVG, screenshot, or custom markup.
| Prop | Type | Default | Description |
| ---------- | ----------------------------------------- | ------- | ------------------------------------ |
| value | T extends string | — | Selected option value (controlled). |
| onChange | (next: T) => void | — | Fires when selection changes. |
| options | ImageSegmentedOption<T>[] | — | value, label, image, disabled?. |
| size | "sm" \| "md" | "md" | Option size. |
| ariaLabel| string | — | Accessible name for the radiogroup. |
import { useState } from "react";
import { ImageSegmented } from "@andreyfedkovich/cozy-ui";
const [layout, setLayout] = useState<"agent" | "editor">("agent");
<ImageSegmented
ariaLabel="Window layout"
value={layout}
onChange={setLayout}
options={[
{
value: "agent",
label: "Agent",
image: (
<svg viewBox="0 0 48 32" aria-hidden>
<rect width="48" height="32" rx="3" fill="#eef3fb" />
<rect x="3" y="3" width="18" height="26" rx="2" fill="#4573d9" opacity="0.85" />
<rect x="23" y="3" width="22" height="26" rx="2" fill="#fff" stroke="#dde5f5" />
</svg>
),
},
{
value: "editor",
label: "Editor",
image: (
<svg viewBox="0 0 48 32" aria-hidden>
<rect width="48" height="32" rx="3" fill="#eef3fb" />
<rect x="3" y="3" width="42" height="26" rx="2" fill="#fff" stroke="#dde5f5" />
</svg>
),
},
]}
/>;Hooks & helpers
Field validation
import {
type FieldMeta,
type ShowErrorPolicy,
resolveFieldError,
resolveFieldMessage,
resolveDisplayError,
resolveShowError,
useFieldState,
useFormFields,
useValidationRequest,
attemptWizardStep,
} from "@andreyfedkovich/cozy-ui";useMeasureElement
Tracks the size of a DOM element via ResizeObserver.
import { useMeasureElement } from "@andreyfedkovich/cozy-ui";
import { useState } from "react";
const [element, setElement] = useState<HTMLDivElement | null>(null);
const { width, height } = useMeasureElement(element);
<div ref={setElement}>{width} × {height}</div>;useDropdownPosition
Calculates a flip-aware dropdown position relative to a trigger. Used internally by Select.
Icons
The library ships its SVG icon set as React components. Tree-shaken, currentColor-aware.
import { DoneIcon, WarnIcon, CrossIcon, SearchIcon, ArrowDownIcon } from "@andreyfedkovich/cozy-ui";Available icons include: ArrowDownIcon, ArrowRightIcon, CameraIcon, CancelIcon, ChartIcon, ChatIcon, CheckGreenIcon, ClockIcon, CloseRedIcon, CopyIcon, CrossIcon, DoneIcon, DownloadIcon, EditIcon, EmptyIcon, EnvelopIcon, FeedbackIcon, FilterIcon, GridIcon, HeartIcon, HelpIcon, HomeIcon, InfoIcon, ListIcon, MarketIcon, MessageIcon, PhoneIcon, PlaneIcon, ProfileIcon, ReloadIcon, SearchIcon, SettingsIcon, WalletIcon, WarnIcon, and more.
TypeScript
Cozy UI is written in TypeScript and ships .d.ts declarations. All public types are re-exported from the package root:
import type {
ButtonVariant, ButtonSize,
CustomOption, SelectColumn,
DialogSelectProps, DialogSelectColumn,
TreeDialogSelectProps, TreeNode,
StepperItem, StepperProps,
TooltipProps, TooltipPlacement, TooltipTrigger,
CarouselProps,
CopyTextTriggerProps,
ApprovalRouteProps, ApprovalLevel, ApprovalStage, Approver, ApprovalStatus,
} from "@andreyfedkovich/cozy-ui";SSR & framework support
Cozy UI runs in Next.js (App / Pages router), TanStack Start, Remix, and any Vite SPA.
Components that use portals — Select, DialogSelect, TreeDialogSelect, Popover, TooltipDark, TooltipLight — render on the client. In Next.js App Router, mark consuming files with "use client" (or import them through a client boundary). In TanStack Start they work out of the box inside route components.
Theming
Override CSS variables in your global stylesheet, after Cozy UI's import:
@import "@andreyfedkovich/cozy-ui/styles.css";
:root {
--cozy-blue-03: #2563eb;
--cozy-radius-md: 14px;
--cozy-shadow-raised: 0 12px 32px -12px rgb(15 23 42 / 0.18);
}For per-component overrides, every component accepts a className prop and uses CSS modules — your class wins over module hashes thanks to a single trailing className slot.
Accessibility
- Dialogs (
DialogSelect,TreeDialogSelect, internal name dialogs) are built on Radix Dialog — focus trap, ESC to close, scroll lock. Labelis built on Radix Label with properfor/idassociation.StepperandTabsare keyboard navigable.- Focus rings respect
:focus-visible, never blanket-suppressed. - Color tokens meet WCAG AA contrast for text-on-surface combinations.
Local development
bun install
bun run dev # demo playground at http://localhost:5173
bun run build:lib # dist-lib/ (ESM + CJS + .d.ts + styles.css / styles.modules.css / styles.tailwind.css)
bun run lint
bun run formatThe demo playground (src/routes/index.tsx) showcases every exported component and is the easiest place to iterate on a new variant.
Publishing
npm publish --access publicprepublishOnly runs build:lib automatically, so you publish exactly what's in dist/. Bump the version in package.json (semver) before each release.
Contributing
PRs are welcome. Please:
- Run
bun run lint && bun run formatbefore pushing. - Add the new component to
src/lib/components/index.tsand demo it insrc/routes/index.tsx. - Document any new prop in this README.
- Record library changes in
CHANGELOG.mdunder Unreleased; when publishing to npm, move them into a dated## X.Y.Zsection and clear Unreleased.
License
MIT © Andrey Fedkovich
