The component library lives in packages/ui and is built on Chakra UI v3. The web app and Storybook consume it via @repo/ui/* imports.
The design system is configured in packages/ui/src/chakra.system.tsx using Chakra v3's createSystem API:
gossamer, bluishGray), spacing, etc.brand, success, error, warning, destructive, sidebar background.packages/ui/src/ThemeProvider.tsx wires up:
ChakraProvider with the custom system.next-themes ThemeProvider with attribute="class" (dark/light mode support).<Toaster /> for toast notifications.To regenerate Chakra types after changing the system:
pnpm --filter @repo/ui chakra:typegen
Chakra v3 introduces colorPalette as a contextual theming mechanism. Setting colorPalette="brand" on a component makes every colorPalette.* token reference inside it resolve against the brand color scale. This replaces the need to hard-code color scales on every child.
colorPalette="brand" (or any palette name).colorPalette.fg, colorPalette.solid, colorPalette.muted in style props.<Box colorPalette="success">
{/* color resolves to success.fg (green.700 light / green.300 dark) */}
<Text color="colorPalette.fg">Saved successfully</Text>
</Box>
The system config in packages/ui/src/chakra.system.tsx establishes several defaults:
| Scope | Default Palette | Effect |
|---|---|---|
:root |
brand |
All components inherit brand unless overridden |
::selection |
gray |
Text selection highlight |
| Button recipe | gray |
Buttons are neutral by default |
| Badge recipe | brand |
Badges use the primary brand color |
| Kbd recipe | gray |
Keyboard shortcuts are neutral |
Additionally, @repo/ui wrapper components like Tabs.Trigger and Switch.Root default to brand via colorPalette ?? "brand".
Semantic palettes are defined in packages/ui/src/lib/createColorPalette.ts via createSemanticColorPalette, which maps a primitive color scale to semantic steps (solid, fg, muted, subtle, emphasized, focusRing, border, contrast, and shades 50--950):
| Palette | Base Scale | Usage |
|---|---|---|
brand |
gossamer |
Primary actions, CTAs, default accent |
info |
blue |
Informational indicators |
success |
green |
Success states, confirmations |
error |
red |
Error states, validation failures |
destructive |
red |
Delete/remove actions (same scale as error, separate semantic name) |
warning |
yellow |
Warnings (uses black contrast text) |
ai |
purple |
AI-related features and accents |
Raw Chakra scales (gray, blue, green, red, purple, yellow, etc.) also work as colorPalette values and are used for finer-grained styling.
Use semantic palette names for intent, not raw colors. Prefer colorPalette="error" over colorPalette="red" so the meaning is clear and the underlying scale can be changed in one place.
Primary actions use brand:
<Button colorPalette="brand" trackingData={{ event: "save" }}>
Save
</Button>
Status indicators map domain states to palettes. Type the map with BoxProps["colorPalette"] for safety:
const STATUS_COLOR_MAP: Record<
SessionProgressStatus,
BoxProps["colorPalette"]
> = {
complete: "success",
processing: "purple",
transcribing: "blue",
failed: "error",
initialized: "gray",
};
<Badge colorPalette={STATUS_COLOR_MAP[status]}>{statusLabel}</Badge>;
Referencing the active palette in child styles -- useful for icons or text that should follow the palette set by a parent:
<SparklesIcon
colorPalette="ai"
color={{ _dark: "colorPalette.fg", _light: "colorPalette.solid" }}
/>
Dynamic palette switching for interactive states like copy buttons:
<IconButton colorPalette={isCopied ? "success" : "gray"} ... />
Each palette created by createSemanticColorPalette provides these steps:
| Token | Light | Dark | Typical Use |
|---|---|---|---|
colorPalette.solid |
500 | 500 | Solid backgrounds, fills |
colorPalette.fg |
700 | 300 | Foreground text, icons |
colorPalette.muted |
200 | 800 | Muted backgrounds |
colorPalette.subtle |
100 | 900 | Very light backgrounds |
colorPalette.emphasized |
300 | 700 | Hover/active states |
colorPalette.border |
500 | 400 | Borders |
colorPalette.focusRing |
500 | 500 | Focus ring outlines |
colorPalette.contrast |
#fff |
#fff |
Text on solid backgrounds |
Components are imported per-file, not from a barrel:
import { Button } from "@repo/ui/Button";
import { Field } from "@repo/ui/Field";
import { Dialog } from "@repo/ui/Dialog";
import { Drawer } from "@repo/ui/Drawer";
import { Table } from "@repo/ui/Table";
Composites and hooks have their own entry points:
import { DataTable, MultiFilterMenu } from "@repo/ui/composites";
import { useDebounce, useMediaQuery } from "@repo/ui/hooks";
Theme hook (separate entry):
import { useTheme } from "@repo/ui/useTheme"; // resolvedTheme, setTheme
The UI package uses three main patterns:
Re-export Chakra components as-is when no extra behavior is needed:
// packages/ui/src/Theme.tsx
export { Theme } from "@chakra-ui/react";
Wrap Chakra components with additional behavior. The Button is the primary example -- it requires analytics trackingData:
// packages/ui/src/Button.tsx
export type ButtonProps = ChakraButtonProps & {
trackingData: AnalyticsEvent | (() => AnalyticsEvent);
};
export const Button = ({ ref, children, ...props }: ButtonProps) => {
const analytics = useAnalytics();
const handleOnClick: ChakraButtonProps["onClick"] = async (event) => {
await trackClickEvent(analytics, props.trackingData);
props.onClick?.(event);
};
return (
<ChakraButton ref={ref} {...props} onClick={handleOnClick}>
{children}
</ChakraButton>
);
};
Every Button in the app must provide trackingData, ensuring analytics coverage by default.
Build richer components using Chakra's slot pattern with static subcomponents:
// packages/ui/src/Field.tsx
export type FieldProps = Omit<ChakraField.RootProps, "label"> & {
label?: React.ReactNode;
helperText?: React.ReactNode;
errorText?: React.ReactNode;
optionalText?: React.ReactNode;
};
const BaseField = forwardRef<HTMLDivElement, FieldProps>(function Field(props, ref) {
const { label, children, helperText, errorText, optionalText, ...rest } = props;
return (
<ChakraField.Root ref={ref} {...rest}>
{label && (
<ChakraField.Label>
{label}
<ChakraField.RequiredIndicator fallback={optionalText} />
</ChakraField.Label>
)}
{children}
{helperText && <ChakraField.HelperText>{helperText}</ChakraField.HelperText>}
{errorText && <ChakraField.ErrorText>{errorText}</ChakraField.ErrorText>}
</ChakraField.Root>
);
});
export const Field = Object.assign(BaseField, {
Label: ChakraField.Label,
HelperText: ChakraField.HelperText,
ErrorText: ChakraField.ErrorText,
RequiredIndicator: ChakraField.RequiredIndicator,
Root: ChakraField.Root,
});
Larger components live in packages/ui/src/composites/:
| Composite | Description |
|---|---|
DataTable |
Built on TanStack Table. Handles column definitions, sorting, and rendering. |
MultiFilterMenu |
Multi-select filter dropdown with atoms and context for state management. |
Import via @repo/ui/composites.
@repo/ui/icons)Icons live inside @repo/ui and are exported via the ./icons entry point. The module re-exports lucide-react icons wrapped with the shared Icon component under semantic names:
// packages/ui/src/icons/index.tsx
export { SearchIcon, ClientsIcon, TemplatesIcon, ... };
Usage:
import { SearchIcon, ClientsIcon } from "@repo/ui/icons";
@repo/use-form)The @repo/use-form package wraps react-hook-form with automatic Zod validation:
// packages/use-form/src/index.ts
export function useForm<TSchema extends FormSchema>(
props: UseFormProps<TSchema>,
) {
return useReactHookForm({
...props,
resolver: zodResolver(props.schema),
});
}
You always pass a schema instead of a resolver:
import { useForm } from "@repo/use-form";
import { z } from "zod";
const schema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
});
function PersonForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
schema,
defaultValues: { firstName: "", lastName: "" },
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Field label="First Name" errorText={errors.firstName?.message}>
<Input {...register("firstName")} />
</Field>
</form>
);
}
Feature-specific form hooks are colocated with their feature:
apps/web/app/(app)/settings/
├── page.tsx
└── lib/
└── usePersonalInformationForm.ts # schema + defaultValues + useForm
The hook encapsulates the schema and defaults. The component only receives control / register.
Re-exports from @repo/use-form for advanced use:
export {
useController,
useFieldArray,
useFormState,
useWatch,
} from "@repo/use-form";
Lower-level internals (e.g. Controller) are available via @repo/use-form/internals.
@repo/analytics-core defines an abstract AnalyticsClient and AnalyticsEvent type.@repo/analytics-react provides AnalyticsProvider and useAnalytics().LoggerAnalyticsClient (logs to console) in apps/web/lib/providers.tsx.Button component automatically tracks clicks via the required trackingData prop.<Button trackingData={{ event: "save_template", properties: { templateId } }}>
Save
</Button>
Or with a lazy factory when properties are computed:
<Button trackingData={() => ({ event: "submit_form", properties: getFormData() })}>
Submit
</Button>
Component stories live in apps/storybook/src/stories/. Run Storybook locally:
pnpm --filter storybook dev # http://localhost:6006
New components can live in one of three places. Use this decision framework to pick the right one.
flowchart TD
Start["New component"] --> DomainCheck{"Is it domain-specific?"}
DomainCheck -->|No| PkgUI["packages/ui"]
DomainCheck -->|Yes| ReuseCheck{"Used by multiple\nroute features?"}
ReuseCheck -->|No| Colocate["feature/components/"]
ReuseCheck -->|Yes| Shared["shared/domain/"]packages/ui -- Generic, Reusable PrimitivesA component belongs in packages/ui when it is not tied to any domain and could be dropped into any app or Storybook story without knowledge of clients, session notes, templates, etc.
Examples: Button, Dialog, DataTable, Field, Tabs, SearchInput, icons.
Characteristics:
@repo/trpc, @repo/db/schema, route definitions, or domain stores.apps/web/shared/<domain>/ -- Domain-Aware, Cross-FeatureA component belongs in shared/ when it composes @repo/ui primitives with domain data (tRPC queries, branded IDs, route definitions) and is imported by two or more route features.
Examples: PatientCombobox, TemplateCombobox, StartSessionDrawer, SessionNoteProgressBadge.
Characteristics:
PatientId, SessionProgressStatus) and uses tRPC queries.index.ts re-exporting components and hooks.components/ + lib/ shape as route features.apps/web/app/(app)/<feature>/components/ -- Page-ScopedA component belongs colocated with its route when it is only used by that single feature.
Examples: ClientsTable, AddClientDrawer, LoginForm, AuditPreviewDrawer.
Characteristics:
components/ subfolder next to its page.tsx.shared/ and @repo/ui.Components move up the ladder as their usage grows:
feature/components/ --> shared/<domain>/ --> packages/ui
Start by colocating. When a second feature needs the same component, move it to shared/. When a component becomes fully generic (no domain imports), promote it to packages/ui.
A real example: CollapsibleSection currently lives in apps/web/shared/session-notes/components/ with a TODO noting it is a candidate for @repo/ui once it is generalized.
Next: Code Style and Tooling