The CircleHealth design system, built on Chakra UI v3.
Every component in src/ is individually importable via subpath exports defined in package.json:
"exports": {
"./*": "./src/*.tsx", // @repo/ui/Button, @repo/ui/Dialog, etc.
"./icons": "./src/icons/index.tsx",
"./composites":"./src/composites/index.ts",
"./hooks": "./src/hooks/index.ts",
"./tests": "./__tests__/mocks/index.ts"
}
import { Button } from "@repo/ui/Button";
import { Table } from "@repo/ui/Table";
import { DataTable } from "@repo/ui/composites";
import { useDebounce } from "@repo/ui/hooks";
import { SearchIcon } from "@repo/ui/icons";
Components are organized into two tiers:
Atoms (src/*.tsx) are the core building blocks -- one file per component at the root of src/. They are either Chakra re-exports or thin wrappers (see Component Patterns below). Import them directly:
import { Card } from "@repo/ui/Card";
import { Field } from "@repo/ui/Field";
Composites (src/composites/) are higher-level components that compose multiple atoms and add significant behavior such as state management or third-party integrations. Each composite gets its own directory and is imported from the composites subpath:
import { DataTable, MultiFilterMenu } from "@repo/ui/composites";
Composites may contain their own internal atoms (e.g. MultiFilterMenu/atoms/) -- sub-pieces that are only meaningful within that composite.
We use two strategies for wrapping Chakra, depending on how much customization a component needs.
For components where we don't need to customize behavior, we re-export directly from Chakra. This gives us a single import surface across the app and the option to add customizations later without changing consumer code.
// Box.tsx
export { Box, type BoxProps } from "@chakra-ui/react";
Other examples: Text, Heading, Stack, Separator, Center, Kbd.
For components that need custom defaults, added behavior, or API sugar, we wrap the Chakra component:
| Component | What the wrapper adds |
|---|---|
Button |
Analytics tracking on click |
Card |
Default rounded: "xl" via styled() |
Field |
Convenience props (label, helperText, errorText, optionalText) |
Tabs |
Default colorPalette on Trigger |
Input |
Explicit ref typing |
SearchInput |
Composes InputGroup + Input + search icon |
Dialog |
Layout with trigger, title, description, footer props |
Components expose one of two API styles -- or sometimes both.
The consumer assembles the UI from subcomponents. This gives maximum flexibility over layout and behavior.
<Table.Root>
<Table.Header>
<Table.Row>
<Table.HeadCell>Name</Table.HeadCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>Alice</Table.Cell>
</Table.Row>
</Table.Body>
</Table.Root>
Other composed-API components: Tabs, Card, Dialog.*, MultiFilterMenu.*.
The consumer passes data or config props and the component renders its own tree. Simpler to use for common cases.
<DataTable data={patients} columns={columns} onSortingChange={setSorting} />
<EmptyState
title="No results"
description="Try a different search"
icon={<SearchIcon />}
/>
We provide contained APIs when a Chakra compound component has a "default" layout that gets repeated across the app. Rather than asking every consumer to compose the same subtree of Root > Content > Indicator > Title > Description, the contained variant accepts config props and assembles that tree internally. This keeps call sites focused on data, not structure. When the default layout doesn't fit, the composed subcomponents are always available as an escape hatch (see Both at once below).
Creating a contained component -- here's the pattern, using EmptyState as an example:
// 1. Define config props that extend the Chakra root props
type EmptyStateProps = ChakraEmptyState.RootProps & {
title: string;
description?: string;
icon?: React.ReactNode;
};
// 2. Build the Chakra subtree internally from those props
function BaseEmptyState({
title,
description,
icon,
children,
...rest
}: EmptyStateProps) {
return (
<ChakraEmptyState.Root {...rest}>
<ChakraEmptyState.Content>
{icon && (
<ChakraEmptyState.Indicator>{icon}</ChakraEmptyState.Indicator>
)}
<ChakraEmptyState.Title>{title}</ChakraEmptyState.Title>
{description && (
<ChakraEmptyState.Description>
{description}
</ChakraEmptyState.Description>
)}
{children}
</ChakraEmptyState.Content>
</ChakraEmptyState.Root>
);
}
// 3. Attach subcomponents so the composed API remains accessible
export const EmptyState = Object.assign(BaseEmptyState, {
Root: ChakraEmptyState.Root,
Content: ChakraEmptyState.Content,
Title: ChakraEmptyState.Title,
Description: ChakraEmptyState.Description,
Indicator: ChakraEmptyState.Indicator,
});
Some components offer a convenient contained default and expose composed subcomponents via Object.assign. Dialog and MultiFilterMenu both follow this pattern:
// Contained -- pass props, get a complete dialog
<Dialog trigger={<Button>Open</Button>} title="Confirm" footer={<Button>Save</Button>}>
Are you sure?
</Dialog>
// Composed -- full control over every piece
<Dialog.Root>
<Dialog.Trigger asChild><Button>Open</Button></Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Title>Confirm</Dialog.Title>
<Dialog.Body>Are you sure?</Dialog.Body>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
All icons are exported from the @repo/ui/icons subpath:
import { SearchIcon, CloseIcon, FilterIcon } from "@repo/ui/icons";
Icons wrap Lucide SVGs using a createIconComponent helper. Each icon renders the Lucide SVG inside the design-system Icon component, so every icon accepts standard IconProps (Chakra icon props + ref).
We also define semantic aliases -- multiple exports can point to the same underlying Lucide glyph when it makes sense in different contexts (e.g. SortDescendingIcon and ArrowDownIcon both use ArrowDown). This keeps usage self-documenting without duplicating SVGs.
Several @repo/ui components have built-in react-hook-form support. When you pass control + name (from @repo/use-form), the component automatically wires into your form via Controller -- no extra imports or manual render prop needed.
Components with built-in hook-form support: Checkbox, Switch, Select, SegmentedControl, PinInput, MultiFilterMenu.
import { useForm } from "@repo/use-form";
import { Checkbox } from "@repo/ui/Checkbox";
import { Select } from "@repo/ui/Select";
const { control } = useForm({ schema: mySchema });
// Just pass control + name -- the component handles Controller internally
<Checkbox control={control} name="agreeToTerms">
I agree to the terms
</Checkbox>
<Select control={control} name="role" items={roleItems} placeholder="Select a role" />
Without hook-form, the same components work as normal controlled/uncontrolled inputs:
<Checkbox
checked={agreed}
onCheckedChange={({ checked }) => setAgreed(checked)}
>
I agree to the terms
</Checkbox>
When a component is inside a form managed by useForm from @repo/use-form, prefer passing control + name directly rather than manually wiring Controller. This keeps form code concise and ensures field names are type-safe (inferred from the Zod schema).
The exported component uses a discriminated union on its props -- when control is present, it delegates to an internal HookFormX function. That function renders Controller from @repo/use-form/internals and maps the RHF field object to the component's value/change API. composeRef from the same package merges the caller's ref with RHF's field.ref where needed.
Creating a new hook-form variant follows this pattern:
import {
Controller,
type ControllerProps,
type FieldValues,
type FieldPath,
} from "@repo/use-form/internals";
// 1. Define HookForm props -- ControllerProps minus `render`, plus the base component props
type HookFormMyInputProps<
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues>,
> = Omit<ControllerProps<TFieldValues, TName>, "render"> & MyInputProps;
// 2. Implement the HookForm variant using Controller
function HookFormMyInput<
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues>,
>({ control, name, ...props }: HookFormMyInputProps<TFieldValues, TName>) {
return (
<Controller
control={control}
name={name}
render={({ field }) => (
<BaseMyInput
{...props}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
);
}
// 3. Branch in the main export based on whether `control` is present
export function MyInput(props: MyInputProps | HookFormMyInputProps<any, any>) {
if ("control" in props) {
return <HookFormMyInput {...props} />;
}
return <BaseMyInput {...props} />;
}
graph TD
ui["@repo/ui"]
utils["@repo/utils"]
use_form["@repo/use-form"]
analytics_core["@repo/analytics-core"]
analytics_react["@repo/analytics-react"]
typescript_config["@repo/typescript-config"]
vitest_config["@repo/vitest-config"]
ui --> utils
ui --> use_form
ui -.-> analytics_core
ui -.-> analytics_react
ui -.-> typescript_config
ui -.-> vitest_config| Script | Description |
|---|---|
lint |
Run Biome checks |
check-types |
Typecheck with tsc --noEmit |
chakra:typegen |
Regenerate Chakra type tokens |
test |
Run Vitest with coverage |
test:watch |
Run Vitest in watch mode |