Skip to content

Composition

How prfct components fit together — parts, the render prop, data attributes for styling, and the rules that keep customizations maintainable.

prfct components are small, composable parts rather than configurable monoliths. A dialog is a Dialog, a DialogTrigger, a DialogContent, a DialogHeader — each one a thin, styled wrapper you can read in a minute.

Parts, not props

<Card>
  <CardHeader>
    <CardTitle>Team members</CardTitle>
    <CardDescription>Invite people to collaborate.</CardDescription>
    <CardAction>
      <Button size="sm">Invite</Button>
    </CardAction>
  </CardHeader>
  <CardContent></CardContent>
  <CardFooter></CardFooter>
</Card>

Parts keep APIs small and let you rearrange, omit or wrap any piece without the component growing a prop for every case.

The render prop

Base UI components render a sensible default element. To render something else — your own component, a router link — pass it to render. Behavior, accessibility and styles are merged onto it.

<DialogTrigger render={<Button variant="outline" />}>Edit profile</DialogTrigger>

<BreadcrumbLink render={<Link href="/projects" />}>Projects</BreadcrumbLink>
Coming from Radix?
Radix uses asChild; Base UI uses render. When a button primitive renders a non-button element, set nativeButton={false} and Base UI adds role="button" and keyboard activation to it.

Navigation belongs on a link, even when it looks like a button. Don't render a Button as an anchor — Base UI would announce it as a button. Put buttonVariants on the link instead:

import { buttonVariants } from "@/components/ui/button"

<Link href="/pricing" className={buttonVariants({ variant: "outline" })}>
  See pricing
</Link>

Styling with data attributes

Every part carries a data-slot attribute, and interactive parts expose their state as data attributes (data-open, data-checked, data-disabled, data-highlighted…). Style against them from a parent without touching the component:

<Field className="*:data-[slot=field-description]:text-xs"></Field>
<SelectItem className="data-highlighted:bg-brand-3"></SelectItem>

Customizing

Prefer these approaches, in order:

  1. Variants and sizes. variant="outline", size="sm".
  2. Layout with className. Width, margin, alignment — layout belongs to the parent.
  3. A new variant. If you need a new look, add it to the component's cva definition so it's shared, documented and consistent.
  4. Edit the source. It's your code. Keep tokens, focus styles and data-slot attributes intact.
Do.className for layout; variants for appearance.
Don’t.Overriding colors per instance fragments the system.

Merging classes

cn() merges class names and resolves Tailwind conflicts, so a className passed to a component overrides its defaults predictably. prfct's cn() knows the system's custom tokens — text-heading-md, shadow-floating, duration-fast — which a generic merger would mis-classify and silently drop.

import { cn } from "@/lib/utils"

cn("text-heading-md text-foreground", "text-muted-foreground")
// → "text-heading-md text-muted-foreground"