@classytic/fluid
v1.19.0
Published
Fluid UI - Custom components built on shadcn/ui and base ui by Classytic
Readme
@classytic/fluid
React 19 component library on top of shadcn/ui + Base UI. Built for Next.js 16 App Router; most entry points are framework-agnostic. ESM-only, server/client split, RSC-friendly, no design-system lock-in (you keep your own shadcn primitives at @/components/ui/*).
The package exposes 106 JavaScript subpaths plus 4 stylesheets: 31 barrels (@classytic/fluid, /forms, /dashboard/client, …) and a granular path for every public form component, client component, and hook (@classytic/fluid/forms/form-input, /client/card-wrapper, /hooks/use-debounce). Reach for the granular paths when per-route compile time matters — see Entry points.
Install
npm install @classytic/fluid1. Install core peer deps
npm install react react-dom lucide-react class-variance-authority clsx tailwind-merge2. Install shadcn primitives fluid wraps (40 components, one command)
npx shadcn@latest add accordion alert alert-dialog avatar badge breadcrumb button calendar card checkbox collapsible combobox command context-menu dialog drawer dropdown-menu empty field hover-card input input-group input-otp item kbd navigation-menu pagination popover progress radio-group resizable scroll-area select separator sheet sidebar skeleton switch table tabs textarea tooltipIf you only need a subset, see the peer-dep table and add only the shadcn components your imports need.
3. Import styles
// app/layout.tsx
import "@classytic/fluid/styles.css"
import "@classytic/fluid/document/print.css" // if you use the document subsystem4. Transpile (Next.js only)
// next.config.ts
export default { transpilePackages: ["@classytic/fluid"] }5. (Optional) Wrap with FluidProvider for i18n label overrides:
import { FluidProvider } from "@classytic/fluid/client/core"
export default function RootLayout({ children }) {
return <FluidProvider>{children}</FluidProvider>
}Tailwind v4 required.
styles.cssuses@sourceand@theme inline— Tailwind v3 throws.Next.js is optional.
dashboard/client,search, andlayoutsimportnext/navigationornext/link;client/galleryandclient/thumbnailimportnext/image. Everything else is framework-neutral — see the peer-dep table.
Quick example
import { useForm } from "react-hook-form"
import { FormInput, FormTextarea, FormSection } from "@classytic/fluid/forms"
import { ClientSubmitButton } from "@classytic/fluid/client/core"
function ContactForm() {
const { control, handleSubmit } = useForm()
return (
<form onSubmit={handleSubmit(console.log)} className="space-y-4">
<FormSection title="Contact">
<FormInput control={control} name="email" label="Email" required />
<FormTextarea control={control} name="message" label="Message" rows={4} />
</FormSection>
<ClientSubmitButton>Send</ClientSubmitButton>
</form>
)
}Form submission — four styles, one contract
FormSurface (from @classytic/fluid/forms) is the <form> element the overlays render. It decides when to render a form, when to prevent the default, and whether native validation runs — so every overlay behaves the same.
React 19 Server Action. Pass action; the submit is not prevented, FormData is collected natively, the form works before hydration, and the submit button derives its pending state from useFormStatus — no submitLoading prop:
<FormDialog open={open} onOpenChange={setOpen} action={createCustomer} title="New customer">
<FormInput name="email" label="Email" required />
</FormDialog>react-hook-form / formkit. The default. noValidate is set for you, so schema rules and focusFirstError run instead of native bubbles:
<FormDialog open={open} onOpenChange={setOpen} onSubmit={form.handleSubmit(save)} title="Edit">…</FormDialog>@classytic/formkit is react-hook-form under the hood, so SchemaForm/SchemaFormDialog/SchemaFormSheet are always this mode — don't pass action to a formkit form, the two submission models are mutually exclusive. Use applyServerErrors(form, fieldErrors) to fold server-returned errors back into the form.
Where to import the builders from. @classytic/fluid/formkit is "use client" — it carries SchemaForm and the shadcn adapter — so importing field/section from it makes your schema a client module too. A schema is plain data; keep it on the server-safe subpath and let the client boundary live with the renderer:
// schedule-schema.ts — no "use client"; importable from an RSC, a server action or a node test
import { defineSchema, field, section } from "@classytic/fluid/formkit/builders"
export const schema = defineSchema({ sections: [section("id", "Details", [field.text("name", "Name")])] })
// form.tsx — the boundary belongs here
"use client"
import { SchemaForm } from "@classytic/fluid/formkit"
import { schema } from "./schedule-schema"@classytic/formkit/server is equally server-safe and is the better choice when you author with field.for<T>() or an explicit <TFieldValues> generic; ./formkit/builders exists for Fluid's de-generic field/section, which let mixed-name, string-path field arrays unify under strict TS.
External form (sheets). FormSheet and EntitySheet bind to a form you render, so a Server Action works there natively. The footer sits outside that form, so useFormStatus can't reach it — pass submitLoading from useActionState:
const [state, formAction, isPending] = useActionState(createCustomer, null)
<EntitySheet open={open} onOpenChange={setOpen} mode="create" entityName="Customer"
formId="customer-form" submitLoading={isPending}>
<form id="customer-form" action={formAction}>
<FormInput name="email" label="Email" required />
</form>
</EntitySheet>Imperative. No form — give submit.onClick a handler and the button becomes a plain button.
Entry points
| Entry | Server-safe | Purpose |
|---|:-:|---|
| @classytic/fluid | ✅ | Layout, display, states, skeletons, utils |
| @classytic/fluid/client/hooks | — | Hooks + storage (no next/navigation) |
| @classytic/fluid/client/core | — | Dialogs, cards, pills, tabs, animations, providers |
| @classytic/fluid/client/table | — | DataTable + toolbar |
| @classytic/fluid/client/theme | — | ModeToggle |
| @classytic/fluid/client/error | — | ErrorBoundary, AsyncBoundary |
| @classytic/fluid/client/calendar | — | EventCalendar |
| @classytic/fluid/client/color-picker | — | Composable HSL color picker |
| @classytic/fluid/client/gallery | — | Image gallery + lightbox |
| @classytic/fluid/client/spreadsheet | — | Editable spreadsheet table |
| @classytic/fluid/forms | — | Form components (react-hook-form integration) |
| @classytic/fluid/floating-label | — | Material-style floating-label inputs |
| @classytic/fluid/dashboard | ✅ | Nav utils, breadcrumbs, types, DashboardContent, PageShell, AppsGrid |
| @classytic/fluid/dashboard/client | — | Sidebar presets, useDashboardBreadcrumbs, HeaderActions |
| @classytic/fluid/dashboard/resource-dashboard | — | ResourceDashboard |
| @classytic/fluid/document | ✅ | Document/print primitives, pagination utils |
| @classytic/fluid/document/client | — | DocumentMultipage, DocumentPaginator |
| @classytic/fluid/numpad | — | <Numpad> + headless useNumpad |
| @classytic/fluid/search | — | Composable search system, URL-synced |
| @classytic/fluid/command | — | Command palette + keyboard shortcuts |
| @classytic/fluid/layouts | — | NavigationBar, ResponsiveSplitLayout |
| @classytic/fluid/seo | ✅ | JSON-LD wrappers + Next 16 Metadata helpers |
All entries resolve
@/components/ui/*from your project's shadcn setup at build time.
Server / Client split — donut pattern
// app/dashboard/layout.tsx — Server Component (no "use client")
import { DashboardContent } from "@classytic/fluid/dashboard"
import { InsetSidebar } from "@classytic/fluid/dashboard/client"
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar"
export default function Layout({ children }) {
return (
<SidebarProvider>
<InsetSidebar brand={brand} navigation={nav} />
<SidebarInset className="min-w-0">
<DashboardContent>{children}</DashboardContent>
</SidebarInset>
</SidebarProvider>
)
}{children} passes through the client boundary as a server-rendered hole — page content is prerendered. Same pattern applies to @classytic/fluid/document and @classytic/fluid/document/client.
Composing a preset yourself? Add the skip link.
DashboardLayoutrenders<SkipLink />first in DOM order and putsMAIN_CONTENT_IDon its<main>, so bypass-blocks (WCAG 2.4.1) is handled for you. Assemble the shell by hand — as above — and you own that. Without it, a keyboard user tabs through every nav item before reaching page content on every navigation:import { MAIN_CONTENT_ID, SkipLink } from "@classytic/fluid" <SidebarProvider> <SkipLink /> {/* first in DOM order, or it skips nothing */} <InsetSidebar brand={brand} navigation={nav} /> <SidebarInset className="min-w-0"> <DashboardContent as="main" id={MAIN_CONTENT_ID}>{children}</DashboardContent> </SidebarInset> </SidebarProvider>
CopilotSidebarandTopbarRailare self-contained shells and render their own — don't add a second one there.
Peer dependencies by entry point
Core peer deps (react, react-dom, lucide-react, class-variance-authority, clsx, tailwind-merge) are always required. Add the rest only when you import the corresponding entry:
| Entry | Additional peer deps |
|---|---|
| dashboard/client, search, gallery | next |
| client/table | @tanstack/react-table, @tanstack/react-virtual |
| client/theme | next-themes |
| client/error | react-error-boundary |
| client/calendar, forms | date-fns |
| forms | react-hook-form |
Required Button size extensions
Fluid uses three icon-button sizes that aren't in stock shadcn: icon-xs, icon-sm, icon-lg. The latest shadcn registry button (Base UI variant) ships them, but if you scaffolded from older shadcn or hand-rolled @/components/ui/button.tsx, add them to the size cva variant:
size: {
// ...existing default | sm | lg | icon
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)]",
"icon-lg": "size-9",
}Used by: DrawerWrapper, FormFieldArray, FormPasswordInput, FormNumberInput, FloatingNumberInput, Search.Input, FileUploadInput.
Framework support
| Framework | Works | Doesn't |
|---|---|---|
| Next.js 15/16 | Everything | — |
| Vite / Remix / plain React | All entries except those that import next/link / next/navigation | dashboard/client, search, layouts, gallery |
| React Server Components | @classytic/fluid, dashboard, document, seo | Everything "use client" |
Docs
- Dashboard — sidebar presets, donut pattern, PageShell, RSC HeaderSection recipe, dynamic badges,
define*builders - DataTable / ResourceDashboard — pagination shapes,
resultoverload, styling escape hatches, Suspense recipe - Document subsystem — single-page, multi-page, paginator, POS receipt recipe
- Register-style primitives —
useBarcodeScan,useWakeLock,<Numpad>,<HotkeyHelpDialog>,<AppsGrid> - Components & hooks index — full cheat-sheet of every export
Customization
Four escape hatches — fluid wraps Base UI but never locks you out:
- Pass your own labeled trigger. Trigger slots accept an element, which Base UI merges its trigger props into:
<ActionDropdown trigger={<Button aria-label="Row actions"><MoreVertical /></Button>} ... /> - Every Base UI root prop passes through, and is typed. All three overlays —
DialogWrapper,SheetWrapper,DrawerWrapper— extendComponentProps<typeof Dialog>/<typeof Sheet>/<typeof Drawer>rather than re-declaring a subset, sodefaultOpen,onOpenChangeComplete,disablePointerDismissal,actionsRef— plus Drawer's full snap-point surface (snapPoint,defaultSnapPoint,onSnapPointChange,snapToSequentialPoints) — and anything Base UI adds later are available to TypeScript consumers — not just accepted at runtime:<DialogWrapper defaultOpen disablePointerDismissal onOpenChangeComplete={track}>…</DialogWrapper> - Reach individual parts with
*Props.contentProps,headerProps,footerProps,titleProps,descriptionProps,triggerPropsspread onto the matching element — that's whereinitialFocus,finalFocus,render,data-*and ARIA go. Styling still goes through the*ClassNameprops, which win over*Propsso a size preset can't be broken by accident.reflands on the popup.<SheetWrapper ref={popupRef} contentProps={{ initialFocus: searchRef }} … /> - Drop to the Base UI primitive when you need full control — every wrapper documents its underlying primitive in JSDoc.
i18n labels (Save / Cancel / Loading... / kit-internal aria) flow from <FluidProvider labels={...}> so non-English a11y trees come out correctly without per-component overrides. Consumer-controlled content (titles, descriptions, empty messages) flows through component props with English defaults — FluidProvider is fully optional.
Dev
npm run build # tsdown — 31 build entries, unbundled to ~570 dist files
npm run typecheck # tsc --noEmit, against the ambient shadcn contract
npm run typecheck:conformance # ...and against the REAL shadcn components in examples/fluid
npm run lint # biome (workspace config at the repo root)
npm test # vitest
npm run check:exports # package.json exports vs entrypoints.mjsentrypoints.mjs is the single source of truth for the public surface — tsdown.config.ts and package.json's exports are both generated from it. Add a subpath there and run npm run sync:exports; check:exports fails the build on drift.
Requirements
React 19+, Next.js 15+, Tailwind v4, shadcn/ui at @/components/ui/*.
License
MIT
Trademark
The code is MIT-licensed. "Classytic", "arc", and the logos are trademarks of Classytic LLC and are not licensed under MIT — see TRADEMARK.md. Forks must be renamed; the license covers the code, not the brand.
