Installation

Bring the component source into your project. Edit it like the rest of your code.

Comwit UI is a curated kit: for each job in an app's UI it picks a proven library (page transitions, toasts, overlays, rich text, tables, dates, forms, chat lists), wraps it under a neutral name and one design system, and installs the wrapper as source you own. Every component card in the gallery links to the library behind it.

Set up

Start with a React project using Tailwind CSS v4. In Next.js, use the App Router.

npx comwit-ui@latest init
npx comwit-ui@latest add button dialog input

init creates comwit.json, adds the token stylesheet, and installs the base dependencies. add copies the requested components and their local dependencies into your project.

Files go where your @/ import alias points. When tsconfig.json maps "@/*" to "./src/*", init records "srcDir": "src" in comwit.json, so components/ui lands in src/components/ui. Change the folders with aliases (ui, lib, and optionally utils for the cn() file, for example "lib/utils/cn" when lib/utils/ already holds other helpers).

Language

Default text that components show or announce (placeholders, button labels, screen-reader names) and the date locale of the pickers live in one copied file, lib/ui-text.ts. Run init --locale ko (or set "locale": "ko" in comwit.json) to install the Korean version. Edit the file to change the wording everywhere; a component's labels, placeholder or title prop still overrides it for one screen. To switch an existing project, change the locale and run add ui-text --overwrite.

Use a component

'use client'

import { Button } from '@/components/ui/button'

export default function SaveButton() {
  return <Button onClick={() => console.log('Saved')}>Save</Button>
}

The copied files belong to your project. @comwit/ui supplies the underlying behavior, including the app-shell hooks: ScrollChromeProvider and useScrollChrome (the scroll intent the app bar and bottom nav share) and useMobile come from @comwit/ui, not from copied files. You do not need to install @comwit/ui-templates separately for this workflow.

Providers

For imperative dialogs/sheets, mount OverlayProvider from overlay-kit once in a client provider. For toast notifications, install toast with the CLI, mount the copied Toaster component once and call its toast(); it renders a glass toast through Sonner. Ordinary buttons and inputs need neither provider.

Mobile app shell

For an app with bottom tabs and full-screen details, comwit-ui add app-shell app-screen installs the whole shell for your router and you only place it. AppShell is the phone frame around the routed area: the one <main> scroller (its structural styles are forced inline by the AppShell primitive, so a sticky bottom nav never jitters and the leaving page always anchors to the scroller), pull to refresh, the scroll intent the app bar and tab bar share, page transitions and history-aware back. TabShell goes in the layout of the tab screens: the body swaps while the tab bar stays. AppScreen and DetailScreen wrap each page with its app bar.

// app/(app)/layout — once around the routed area
<AppShell tabs={APP_TABS}>{children}</AppShell>
// app/(app)/(top-level)/layout.tsx — tab screens
<TabShell>{children}</TabShell>
// app/(app)/(detail)/layout.tsx — full-screen details
{children}

Tab paths (tabs[].href) keep the tab bar and slide sideways in tab order; every other route drills in and takes the bar with it. Pass transitions={appTransitions(tabPaths, { transitions: [...] })} from page-transition to add sheet or zoom rules on top of that default. The Next.js variant reads each layout's selected segments, so an intercepted @modal route keeps the tab underneath; the React Router and TanStack Router variants use the pathname; the generic one takes path and onNavigate. Nothing in the shell is a black box: the copied app-shell.tsx is the composition, and the frame width, background and refresh dial are yours to restyle.

Page transitions

page-transition installs the provider, a route boundary for your router and the presets (drill, sheet, slide, axis, zoom, hero, fade) as plain component source, the same way as any other component. Wrap the routed area once:

'use client'

import { PageTransition, drill, sheet } from '@/components/ui/page-transition'
import { RouteBoundary } from '@/components/ui/route-boundary'

const transitions = {
  transitions: [
    { on: '/posts/**', except: '/posts', transition: drill() },
    { on: '/compose', transition: sheet({ type: 'blur' }) },
  ],
}

export function AppRoot({ children }: { children: React.ReactNode }) {
  return (
    <PageTransition config={transitions}>
      <RouteBoundary>{children}</RouteBoundary>
    </PageTransition>
  )
}

The CLI reads package.json and installs the RouteBoundary for Next.js, React Router or TanStack Router; pass --router to choose, or use the generic one and hand it your router's pathname. The Next.js and TanStack Router versions wrap ssgoi's own adapters (@ssgoi/react/nextjs, @ssgoi/react/tanstack-router) and only add the PageBoundary defaults.

The whole contract is one keyed element: RouteBoundary renders a PageBoundary whose React key and route id are the pathname. When the key changes, React unmounts the old page and mounts the new one, and the engine keeps the detached node, lays it position: absolute over the new page and animates both. That is why it needs no router at all: PageBoundary takes path directly, so a useState value works the same way, which is how the gallery demos drill, sheet and hero.

PageTransition lays a layout-only shell (flex min-h-dvh flex-col); inside your own scroller pass className="min-h-full". The scroller holds everything else, the way a native app shell is built: it anchors the leaving page (relative), contains its stacking (z-0) and clips it sideways (overflow-x-clip). Give your scroller relative z-0 overflow-x-clip overflow-y-auto and put PageTransition at its top, or put relative z-0 overflow-x-clip on body when the document scrolls. Keep any element with its own overflow out of the path between the scroller and a sticky bottom nav, and keep the scroller a stacking context; either one makes iOS Safari reposition the nav late while it scrolls, so it jitters. Use clip, never overflow-x-hidden, which would turn that element into a scroll container. Give every page a background; PageBoundary is min-h-full by default so a short page stays as tall as the shell while it leaves and its sticky bars do not ride up. The app bar belongs to the page, inside the boundary, so it moves with the page. A shell that survives tab changes keeps only the bottom nav: an outer boundary with a fixed routeKey, and an inner boundary that swaps the page:

<RouteBoundary routeKey="tabs" className="flex min-h-full flex-col">
  <RouteBoundary className="flex-1">{children}</RouteBoundary>
  <BottomNav>…</BottomNav>
</RouteBoundary>

The leaving page is laid position: absolute at the top of its nearest positioned ancestor, so if you ever put chrome above a nested boundary, give that boundary a relative parent or it jumps up by the chrome's height.

hero() carries one element between two pages: give it data-hero-exit-key={id} where you leave and data-hero-enter-key={id} where you arrive, plus data-hero-radius (the corner radius in px) on both ends so the corners morph with the box. Rules, every preset, persistent layouts, scroll restoration and troubleshooting are documented by SSGOI, the engine behind it; the config shape is identical, only the component names differ.

Bottom sheet

bottom-sheet installs a sheet that rises from the bottom with a grab handle. Drag the handle down, or the body while its scroller is at the top, and the sheet follows your finger; past a quarter of its height or with a quick flick it closes, otherwise it springs back. The gesture and its thresholds live in the BottomSheet primitive of @comwit/ui; the copied file draws the glass surface and the handle.

'use client'

import {
  BottomSheet,
  BottomSheetContent,
  BottomSheetDescription,
  BottomSheetFooter,
  BottomSheetHeader,
  BottomSheetTitle,
  BottomSheetTrigger,
  BottomSheetClose,
} from '@/components/ui/bottom-sheet'

;<BottomSheet>
  <BottomSheetTrigger asChild>
    <Button variant="outline">Filters</Button>
  </BottomSheetTrigger>
  <BottomSheetContent>
    <BottomSheetHeader>
      <BottomSheetTitle>Filters</BottomSheetTitle>
      <BottomSheetDescription>Drag the handle down to dismiss.</BottomSheetDescription>
    </BottomSheetHeader>
    <div className="min-h-0 flex-1 overflow-y-auto px-5">…</div>
    <BottomSheetFooter>
      <BottomSheetClose asChild>
        <Button>Apply</Button>
      </BottomSheetClose>
    </BottomSheetFooter>
  </BottomSheetContent>
</BottomSheet>

Give a long body min-h-0 flex-1 overflow-y-auto. drag="handle" limits the gesture to the handle, closeThreshold and velocityThreshold tune when a release closes, and container portals the sheet into an app shell instead of body. popup.sheet() opens the same component imperatively.

Chat

chat installs one file with the whole conversation screen as compound parts: Chat fills its parent, ChatHeader sits on top, ChatMessages is a virtualized list (built on React Virtuoso inside @comwit/ui) and ChatComposer is the input. The file is styling only: the scroll rules, virtualization, Enter/IME handling and auto-grow are the Chat primitive in the engine, so you can restyle every part without touching behavior. Give the parent a height, then compose:

'use client'

import {
  Chat,
  ChatBubble,
  ChatComposer,
  ChatHeader,
  ChatMessage,
  ChatMessages,
  ChatTitle,
} from '@/components/ui/chat'

export function Thread({ messages, send }: Props) {
  return (
    <div className="h-dvh">
      <Chat mode="messenger">
        <ChatHeader>
          <ChatTitle>Ava Chen</ChatTitle>
        </ChatHeader>
        <ChatMessages items={messages} isOwn={(m) => m.mine}>
          {(m) => (
            <ChatMessage align={m.mine ? 'end' : 'start'}>
              <ChatBubble>{m.text}</ChatBubble>
            </ChatMessage>
          )}
        </ChatMessages>
        <ChatComposer onSend={send} />
      </Chat>
    </div>
  )
}

mode chooses the scroll rules. messenger (people) lands your messages at the bottom and follows theirs only while you are already there; scrolled up, a glass pill counts the new ones. assistant (AI) lifts the message you send to the top and streams the answer under it, the way chat assistants do. Both open at the last message. Pass footer={<ChatTyping />} while the other side is typing, pending and onStop to the composer while an answer streams, and give ChatMessages a key per conversation so a new thread starts at its own end. The data shape is yours: items can be anything, isOwn tells the list which messages are yours (the default reads role === 'user').

Customize

Edit the installed component files, or override the CSS variables in your stylesheet. See Tokens & theming.

The CLI preserves existing files. Use --overwrite only when you want to replace your local changes. --dry previews operations; --no-install skips installing npm dependencies.

With an agent

Use Comwit UI's llms.txt. State has its own implementation guide.