Components
How components are added, shaped, styled, and organized. This skill owns authoring and styling rules; what components exist and what they look like is the design contract's concern, not this skill's.
The flow: shadcn first
- Check shadcn before writing anything. Primitives use shadcn, which uses Radix primitives underneath. Adding a new component starts with a check of the shadcn library: if it exists there, import it with the CLI (
pnpm dlx shadcn@latest add <name>), then reformat it to the house shape below and re-theme it with the project's design tokens. - Custom components use the identical shape. If shadcn doesn't have it (or the design calls for something bespoke), author it from scratch in exactly the same format; the only difference is there's nothing to import.
- When a library component can't express the design, fork it once. Copy it into the project's own component directory, reshape it there (content as markup rather than fixed slots, surfaces as variants,
classNamereaching the element that needs it), and build every usage on that one fork. Its TSDoc names the constraint that forced the fork. Reaching past it to the underlying primitive at a call site is how a codebase ends up with four tooltips that drift apart.
The file shape
Every component follows this example skeleton:
import { forwardRef, type HTMLAttributes } from 'react' import { cn, cva, VariantProps } from '@/utils/theme' const styles = { root: cva('…', { variants: { … }, defaultVariants: { … } }) } type ExampleRef = HTMLDivElement type ExampleProps = HTMLAttributes<ExampleRef> & VariantProps<typeof styles.root> const Example = forwardRef<ExampleRef, ExampleProps>((props, ref) => { // props const { className, ...rest } = props // hooks // render vars // jsx return <div ref={ref} className={cn(styles.root({ className }))} {...rest}></div> }) Example.displayName = 'Example' export { Example } export type { ExampleProps, ExampleRef }Order within the file: CVA styles/constants on top → types (
XxxRef,XxxProps) → component. Order within the function body, each under its comment: props destructure → hooks → render vars → handlers → jsx (composed withcn()). Sections with nothing in them are omitted.Named exports only: the component plus its
PropsandReftypes;displayNameset onforwardRefcomponents. No default exports.
Styling
- CVA for all visual variants. Each component defines a local
stylesobject ofcva()calls; never ad-hoc conditional className logic at call sites. - No class string ever appears in JSX. Every class lives in the
stylesobject under a name, including one-offs with no variants (icon: cva('size-5')) — markup reads as structure, and a component's whole visual surface is one object at the top of the file. That means noclassName="…"literal, and no literal passed tocn()alongside a style entry; add or extend the entry instead. cn()for all className composition:twMerge(clsx(...))fromutils/theme.ts, which also re-exportscvaandVariantPropsso components have one import point.- Layout utilities at the call site, visual styles in the CVA.
w-full, grid placement, margins come from the parent; color, radius, type, borders live in the component's variants. - Semantic tokens only. No raw hex, no palette utilities (
text-zinc-400), no arbitrary color values in JSX. Backgrounds pair with their foregrounds (bg-primary→text-primary-foreground). - Type comes from the ramps. Use the heading/expressive/body typography utilities defined by the design tokens: no arbitrary
text-[13px]. Uppercase is CSSuppercase; content is written in normal case. - Tailwind v4 CSS-first. All theme extension in
themes/theme.css@theme; notailwind.config.ts. asChild+ RadixSlotwhen a component delegates rendering (<Button asChild><Link …/></Button>); never nest interactive elements.
Comments
- Inside a component, the only comments are the section markers from the file shape —
// props,// hooks,// render vars,// handlers,// jsx. No explanatory prose in the body, none inside JSX, and never TSDoc: a component body is read as a sequence of the same five sections in every file, and prose between them breaks that scan. - Rationale goes on something declared at module scope — the component's own TSDoc block, a
styleskey, a type member, a constant, or a helper function. Anything that can't be attached to a declaration is usually a comment that shouldn't be written. - TSDoc blocks are for declarations only: constants, types and their members, object keys, functions, and component definitions. Line comments (
//) carry short notes on those same declarations. - Lint directives are not comments.
// eslint-disable-next-line …stays wherever the rule requires it.
Organization: atomic design
- Components live in
components/in an atomic-design structure:atoms/: simple components. E.g. a button, a badge, the wordmark.molecules/: collections of atoms. E.g. a search bar, a date picker, an accordion, a project card.organisms/: collections of atoms/molecules. E.g. a page block, a dialog, a menu, an overlay.templates/: layout-focused things: footer, header, layout, main, section.
- shadcn CLI imports land in
atoms/(thecomponents.jsonuialias points there) and are reformatted on arrival.
Layout shell
One persistent shell wraps every page, composed from
templates/:layout ├── underlays # site-wide background layers (e.g. custom webgl backgrounds) ├── header # top bar: identity + nav links ├── main # page content, sections in flow ├── footer # bottom bar: copyright, quick links └── overlays # takeover layers (e.g. loading screen, nav overlay)Pages are stacks of
sectionshells. Every section renders through thesectiontemplate: a full-bleed wrapper with a centered inner container in one of three widths (lg/md/sm, values set by the design tokens), never ad-hoc page-level wrappers.
Reusability
- All components are reusable by construction. Content is passed in via props or children, never defined inside a component. Content is defined in
constants/, API calls, or pages (the consuming layer) only. A component with a hardcoded heading is a bug.