Files
probo/contrib/claude/ui.md
Émile Ré 4d8d26aad2 Split Link/Anchor from button navigators
Rename button-styled Link/Anchor to ButtonLink/
ButtonAnchor and add underlined text Link/Anchor
so names match look and element. Hero meta uses
plain Anchors for contact and custom links.

Signed-off-by: Émile Ré <emile@probo.com>
2026-07-31 10:59:20 +02:00

27 KiB

UI system (@probo/ui v2 kit)

Shared React UI for Probo apps lives in the @probo/ui workspace package (packages/ui). The v2 kit (packages/ui/src/v2) is the target system: a flat set of components styled on top of a headless primitive library, consuming the Radix-scale v2 theme. This document describes how to build and style those components.

These rules are the source of truth. The legacy tree (Atoms/, Molecules/, Layouts/, clsx-mixed className, imperative DialogRef) is non-compliant code to migrate, not precedent.

Topic Guide
Component shape, props, naming/suffixes contrib/claude/react-components.md
v2 design tokens (color, type, radius, shadow, z-index, spacing) contrib/claude/v2-tokens.md
App folder layout and special folders contrib/claude/app-arborescence.md
Error boundaries and error/fallback props contrib/claude/error-handling.md
Relay data loading contrib/claude/relay.md

Package and tooling

Item Convention
Package @probo/ui — v2 components under src/v2. Apps opt into v2 by importing the v2 theme (see v2-tokens.md).
Styling Tailwind v4 with the Radix-scale tokens (bg-sand-3, text-sand-12, rounded-3, text-4, …).
Variants API tailwind-variants/lite only — import { tv } from "tailwind-variants/lite". The /lite entrypoint ships without tailwind-merge, which is required: the numbered scales (text-1…9, rounded-1…6, shadow-1…6, z-1…6) collide with the color/utility namespaces and tailwind-merge would silently drop the scale class (e.g. text-3 next to text-sand-11). The legacy v1 kit stays on tailwind-variants (with merge).
Class composition Do not use clsx or tailwind-merge. All conditional styling goes through tv variants and slots.
Headless primitives Base UI (@base-ui/react). We style these primitives; we do not re-implement their behavior.

Preview components with Storybook from packages/ui: npm run dev (Storybook on port 6006).

Base UI is published as @base-ui/react (the earlier @base-ui-components/react name is frozen at an RC — do not use it). Import parts from per-component subpaths, e.g. @base-ui/react/dialog.

Headless primitives: style, don't re-implement

Interactive components (dialogs, popovers, menus, selects, tabs, tooltips) are Base UI primitives with our styling applied — nothing more. The job of a v2 component is to bind tv classes to the primitive's parts. Do not add a custom behavior layer on top.

Rules:

  • Use the primitive's controlled API as-is. A dialog is controlled with open / onOpenChange — the exact same API Base UI exposes. Opening our dialog is opening the lib's dialog.
  • No custom imperative ref API. Never invent useDialogRef() / ref.current.open() / ref.current.close(). If imperative control is genuinely needed, use the primitive's own mechanism (e.g. Base UI's Dialog.createHandle() / actionsRef), never a hand-rolled useRef + useEffect shim.
  • No cloneElement / Children.map plumbing. Compose with the primitive's parts and asChild-style props the library provides, not by cloning children to inject className/handlers.
  • No local mirror state. Don't copy open into useState and sync it with useEffect; pass open/onOpenChange straight through, or let the primitive stay uncontrolled.

Do / don't: dialog wrapper

// Bad — hand-rolled imperative ref, mirrored open state, cloneElement plumbing
export const useDialogRef = () => useRef(null);

export function Dialog({ trigger, ref, children }: Props) {
  const [open, setOpen] = useState(false);
  useEffect(() => {
    if (ref) ref.current = { open: () => setOpen(true), close: () => setOpen(false) };
  });
  // ... Children.map / cloneElement to inject classes ...
  return <Root open={open} onOpenChange={setOpen}>{/* … */}</Root>;
}
// Good — thin styling over Base UI; consumers use the lib's open/onOpenChange directly
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
import { tv } from "tailwind-variants/lite";

const dialog = tv({
  slots: {
    backdrop: "fixed inset-0 bg-sand-12/40",
    popup: "fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-4 bg-sand-2 p-6 shadow-4",
    title: "text-4 font-medium text-sand-12",
  },
});

export type DialogProps = ComponentProps<typeof BaseDialog.Root>;

export function Dialog(props: DialogProps) {
  return <BaseDialog.Root {...props} />;
}

export function DialogPopup({ children, ...props }: ComponentProps<typeof BaseDialog.Popup>) {
  const { backdrop, popup } = dialog();
  return (
    <BaseDialog.Portal>
      <BaseDialog.Backdrop className={backdrop()} />
      <BaseDialog.Popup className={popup()} {...props}>
        {children}
      </BaseDialog.Popup>
    </BaseDialog.Portal>
  );
}
// Good — consumer controls it the same way they'd control the Base UI dialog
const [open, setOpen] = useState(false);

<Dialog open={open} onOpenChange={setOpen}>
  <DialogTrigger>Open</DialogTrigger>
  <DialogPopup>
    <DialogTitle>Delete third party</DialogTitle>
    {/* … */}
  </DialogPopup>
</Dialog>

tailwind-variants and className

In a single component file, do not mix arbitrary Tailwind utility strings on className with tailwind-variants for the same styling concern. Layout and look live in tv slots and variants (and the override APIs tv exposes). Extensibility is exposed through variant props or documented slot/class hooks — never by sprinkling raw utilities (or clsx) beside tv() output.

For compound / multi-slot components, define tv in a dedicated variants.ts module (see Variants file) so loading-only code paths can import styles without pulling the full interactive implementation.

Do / don't: tv vs raw className

// Bad — same file mixes tv() output with ad-hoc Tailwind / clsx on className
import { clsx } from "clsx";
import { tv } from "tailwind-variants/lite";

const row = tv({ base: "flex items-center gap-2" });
export function Row({ children }: { children: ReactNode }) {
  return <div className={clsx(row(), "rounded-3 border border-sand-6")}>{children}</div>;
}
// Good — layout and look live in tv
import { tv } from "tailwind-variants/lite";

const row = tv({
  base: "flex items-center gap-2 rounded-3 border border-sand-6",
});
export function Row({ children }: { children: ReactNode }) {
  return <div className={row()}>{children}</div>;
}
// Good — optional styling toggles use tv variants, not extra className strings
import { tv } from "tailwind-variants/lite";

const row = tv({
  base: "flex items-center gap-2",
  variants: {
    bordered: { true: "rounded-3 border border-sand-6", false: "" },
  },
  defaultVariants: { bordered: true },
});
export function Row({ bordered, children }: { bordered?: boolean; children: ReactNode }) {
  return <div className={row({ bordered })}>{children}</div>;
}

No structure-changing variants

Variants tune look (size, tone, density) — they must not change a component's structure, semantics, or prop contract. When a "variant" would render a different element, accept different props, or fork the behavior, build a separate component instead. This keeps each component's typing simple and its rendered element predictable.

The clearest case is navigation vs action: a clickable action, button-looking navigation, and underlined text links are separate components — not one Button with an as/href/to union, and not Link/Anchor that secretly look like buttons.

Component Element Look
Button <button> button
ButtonLink react-router button
ButtonAnchor <a> button
Link react-router underlined text
Anchor <a> underlined text

Do / don't: separate components over polymorphic props

// Bad — one component forks structure on props; typing becomes a union mess
type ButtonProps =
  | { as?: "button"; onClick: () => void }
  | { as: "a"; href: string }
  | { as: "link"; to: string };

export function Button(props: ButtonProps) {
  if (props.as === "a") return <a href={props.href} className={button()} />;
  if (props.as === "link") return <RouterLink to={props.to} className={button()} />;
  return <button onClick={props.onClick} className={button()} />;
}
// Good — flat components; button look and text-link look stay separate
// Button/variants.ts
export const button = tv({ base: "inline-flex items-center …", variants: { /* size, tone */ } });

// Button.tsx — action
export function Button(props: ComponentProps<"button">) {
  return <button className={button()} {...props} />;
}

// ButtonAnchor.tsx / ButtonLink.tsx — navigation that looks like a button
export function ButtonAnchor(props: ComponentProps<"a">) {
  return <a className={button()} {...props} />;
}
export function ButtonLink(props: ComponentProps<typeof RouterLink>) {
  return <RouterLink className={button()} {...props} />;
}

// Link/variants.ts — underlined text
export const link = tv({ base: "underline …", variants: { /* size, color */ } });

// Link.tsx / Anchor.tsx — navigation that looks like a link
export function Link(props: ComponentProps<typeof RouterLink>) {
  return <RouterLink className={link()} {...props} />;
}
export function Anchor(props: ComponentProps<"a">) {
  return <a className={link()} {...props} />;
}

Size/tone differences (size="sm", tone="danger") are legitimate tv variants — they don't change the element or props.

Props typing

When a component renders a native HTML element (or a single primitive part) as its top-level node, merge the component's own props with that element's intrinsic props via ComponentProps. Destructure custom props and spread the rest so callers can pass standard attributes (id, className, aria-*, event handlers) without wrapper boilerplate.

Do / don't: props merging

// Good — own props merged with the native element's props, rest spread onto <span>
type TextProps = ComponentProps<"span"> & { tone?: "default" | "muted" };

export function Text(props: TextProps) {
  const { tone = "default", className, ...spanProps } = props;
  return <span className={text({ tone, className })} {...spanProps} />;
}
// Bad — only custom props accepted; callers cannot set id, className, aria-*, etc.
type TextProps = { children: ReactNode };

export function Text(props: TextProps) {
  return <span>{props.children}</span>;
}

Icons

Icons come from two sources, in this order of preference:

  1. @phosphor-icons/react — the default icon library. Import the specific icon directly: import { CookieIcon } from "@phosphor-icons/react". Prefer phosphor whenever it has the icon you need.
  2. @probo/ui Icon* set — curated in-house icons. Use these only when phosphor has no suitable equivalent or you need a bespoke Probo-branded icon.

Never use emoji characters (🍪, , ⚠️, …) as icons. Emojis render inconsistently, don't inherit currentColor, and can't be sized like an SVG. If neither source has what you need, add the icon to @probo/ui.

Phosphor import style

Always import phosphor icons by their Icon-suffixed name (e.g. EyeIcon, CookieIcon). Never import the bare name and alias it with an Icon prefix.

// Bad — bare name aliased to add an Icon prefix
import { Eye as IconEye } from "@phosphor-icons/react";

// Good — use the Icon-suffixed export directly
import { EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";

Do / don't: icon source

// Bad — emoji used as an icon
<div className="mb-2 text-9">🍪</div>
// Good — phosphor icon as the default choice
import { CookieIcon } from "@phosphor-icons/react";

<CookieIcon size={48} weight="duotone" className="text-sand-11" />

Folder layout

The v2 tree is flat — there is no Atoms/ / Molecules/ / Layouts/ hierarchy.

  • Simple and layout primitives live in usage-oriented folders: typography/, form/, layouts/.
  • A complex component gets its own folder named after the component (e.g. Dropdown/, Dialog/, ImageCard/), holding its parts, variants.ts, and skeleton.

Do / don't: folder placement

// Good — usage folders for primitives, component folder for composites
packages/ui/src/v2/
  typography/Text.tsx
  typography/TextSkeleton.tsx
  form/Field.tsx
  layouts/CenteredLayout.tsx
  Dialog/Dialog.tsx
  Dialog/DialogPopup.tsx
  Dialog/variants.ts
  Dropdown/Dropdown.tsx
  Dropdown/DropdownItem.tsx
  ImageCard/variants.ts
  ImageCard/ImageCardRoot.tsx
  ImageCard/ImageCardShell.tsx
  ImageCard/ImageCardSkeleton.tsx

// Bad — primitive buried in an ad-hoc folder (belongs under typography/)
packages/ui/src/v2/RandomFolder/Text.tsx

// Bad — legacy classification folders
packages/ui/src/v2/Atoms/Button.tsx

Naming

UI-kit components use bare names (no role suffix): Button, Anchor, Link, Text, List, ListItem, Dialog. Parts of a complex component are prefixed with the component name (DialogPopup, DropdownItem). App-level components use the suffix taxonomy in react-components.md.

Primitives vs compound components

Components fall into two categories: primitives and compound components.

Primitives

Primitives (Text, Avatar, Badge, form inputs, layout helpers) are self-contained — they render a single semantic element with their own styling. A primitive is its own shell: there is no separate shell wrapper. Each primitive has a paired skeleton (TextSkeleton, AvatarSkeleton) that matches its dimensions.

Compound components

Compound components (Card, Dropdown, …) assemble multiple primitives into a larger region. When logic (state, effects, data) lives inside the top-level component, a shell separates layout from behavior:

  • Shell — pure layout frame that accepts region props (media, text, …) as ReactNode and applies tv slot classes. No state, no effects, no data.
  • Root — owns the logic and renders the shell, passing primitives into its region props.
  • Skeleton — reuses the same shell with skeleton primitives, so the loading placeholder is structurally identical without pulling in the logic graph.

If a compound component is purely presentational (no logic), there is no Root — expose only the Shell.

Typography: components over raw text elements

Content text — prose, labels, headings, inline code — renders through the kit's typography components (Text, Heading, Code, …), not raw <p> / <span> / <h1> / <code> with hand-applied token classes. A typography component encodes three decisions in one place so app authors don't re-make them on every element:

  1. Tokens — the numbered type step + color step (text-3 text-sand-12, text-1 text-sand-11). v2 wipes Tailwind's default type scale (see v2-tokens.md), so every raw element would otherwise re-pick these.
  2. Semantics — the component renders the correct semantic element (a real heading, paragraph, or <code>) so the document outline stays tied to the visual hierarchy rather than drifting from it.
  3. Skeleton pairing — each primitive has a matching *Skeleton (TextSkeleton); raw spans have no loading placeholder.

Because these primitives merge the native element's props (see Props typing), using Text over span loses nothing — id, className, aria-*, and handlers still pass through.

Use a component when the node is typography

// Good — typography flows through kit components
<Heading>Measures</Heading>
<Text tone="muted">{description}</Text>
<Code>npm run dev</Code>

Use a raw element only when it isn't typography

Raw <p> / <span> / <h*> / <code> belong in exactly two places:

  • Inside the kit, authoring Text / Heading / Code themselves — a primitive is its own shell, so its raw element lives there and nowhere else.
  • Structural, non-textual nodes — a layout <div> / <section> / <ul>, styled with tv. These are not typography and must not be wrapped in a typography component.
// Bad — raw heading/body with re-picked tokens scattered across app code
<h1 className="text-6 font-medium text-sand-12">Measures</h1>
<p className="text-3 text-sand-11">Description</p>

// Bad — wrapping a layout container in a typography component
<Text><div className="flex flex-col gap-3"></div></Text>

Rule of thumb: if a designer would call it "a heading," "body text," "a caption," or "inline code," it is a kit component. If it's a box, a row, or a layout region, it's a raw element styled with tv.

Skeletons

Every meaningful component provides a paired loading UI named ComponentName / ComponentNameSkeleton (e.g. Text / TextSkeleton).

Skeletons are typography and shapes only — pulse blocks sized to match the real layout (a TextSkeleton matches a line of text; a Dialog exposes a DialogSkeleton matching its frame). They must render instantly and carry no data-fetching logic.

Skeletons must stay out of the heavy bundle

A skeleton's whole point is to render before the real component (and its dependencies) load. A skeleton must therefore be importable without dragging in Base UI or other heavy interactive dependencies.

  • Keep tv slot definitions in a standalone variants.ts (see Variants file). The shell and the skeleton import variants.ts; neither imports the Root's logic.
  • Export each *Skeleton as a standalone named export from its own module — never as a property on a namespace object (Dialog.Skeleton) and never re-exported from a barrel that also pulls the interactive implementation into the same chunk.
  • A complex component exposes its own skeleton (DialogSkeleton, ImageCardSkeleton) so pages can show a faithful placeholder; that skeleton renders the shell + skeleton primitives, importing none of the Base UI parts.

Do / don't: skeleton naming and bundle safety

// Good — paired names, skeleton imports only shell + skeleton primitives
export function ImageCard(props: ImageCardProps) { /* … */ }

// ImageCardSkeleton.tsx — no Base UI / Root imports reach this module
import { ImageCardShell } from "./ImageCardShell";
import { ImageSkeleton } from "../media/ImageSkeleton";
import { TextSkeleton } from "../typography/TextSkeleton";

export function ImageCardSkeleton() {
  return <ImageCardShell image={<ImageSkeleton />} text={<TextSkeleton />} />;
}
// Bad — skeleton nested on a namespace object (pulls the full interactive module in)
import { ImageCard } from "@probo/ui";
<ImageCard.Skeleton />

// Bad — unrelated name / missing pair
export function LoadingText() { /* … */ } // use TextSkeleton instead

Compound component structure (e.g. ImageCard)

Multi-region UI is exported as individual named exports — one per sub-component — all prefixed with the feature name (e.g. ImageCardRoot, ImageCardShell, ImageCardSkeleton). Do not group sub-components as static properties on a namespace object (ImageCard.Root, …); flat named exports enable proper tree shaking and keep heavy dependencies out of loading-time bundles.

  • One directory per feature component. Heavy logic may live in separate files; each public part is a standalone named export.
  • ImageCardRoot — top-level container when it holds logic (state, effects, data wiring).
  • ImageCardShellpure layout shell: takes region props (image, text, …), each a ReactNode, and places them in matching tv slots. No children for layout regions, no state, no logic. If the outer wrapper is layout-only, expose it as ImageCardShell, not ImageCardRoot.
  • Image and Text — shared primitives from the kit, not prefixed under ImageCard. ImageCardRoot composes them into ImageCardShell's region props.

tailwind-variants slots

Model regions with tv slots named after the layout:

// ImageCard/variants.ts
import { tv } from "tailwind-variants/lite";

export const imageCard = tv({
  slots: {
    shell: "flex gap-4 rounded-4 border border-sand-6 p-4",
    image: "shrink-0 overflow-hidden rounded-3",
    text: "min-w-0 flex-1 flex flex-col gap-1",
  },
});

ImageCardShell calls imageCard(), destructures the slots, and mounts each slot's class on a wrapper element around the prop node:

// ImageCard/ImageCardShell.tsx — slot classes on wrapping tags
import { imageCard } from "./variants";

export function ImageCardShell({ image, text }: { image: ReactNode; text: ReactNode }) {
  const { shell, image: imageSlot, text: textSlot } = imageCard();
  return (
    <div className={shell()}>
      <div className={imageSlot()}>{image}</div>
      <div className={textSlot()}>{text}</div>
    </div>
  );
}
// ImageCard/ImageCardRoot.tsx — Root owns logic; Shell receives region nodes as props
import { Image, Text } from "@probo/ui";
import { ImageCardShell } from "./ImageCardShell";

export function ImageCardRoot({ image, text }: { image: ReactNode; text: ReactNode }) {
  // state, effects, data wiring …
  return <ImageCardShell image={<Image>{image}</Image>} text={<Text>{text}</Text>} />;
}

// Bad — Shell takes regions as children instead of image / text props
// Bad — data hooks or state live on Shell (move to Root or above)

Variants file

Keep the tv({ slots: { … } }) definition (and derived slot functions) in a standalone variants.ts next to the component folder. Import it from the shell and skeleton modules so skeleton entry points can pull variants + shell without the rest of the compound component's business logic (and without Base UI).

// Bad — variants defined inside ImageCardRoot.tsx; the skeleton importing it drags Root + hooks (+ Base UI)
// ImageCardRoot.tsx
const imageCard = tv({ slots: { shell: "...", image: "...", text: "..." } });

// Good — shared variants module imported by ImageCardShell and ImageCardSkeleton only
// variants.ts        — export imageCard (or slot helpers)
// ImageCardShell.tsx — import { imageCard } from "./variants"
// ImageCardSkeleton.tsx — import { imageCard } from "./variants"

User feedback (toasts)

Transient feedback for an action's outcome uses Base UI's Toast (@base-ui/react/toast) — never alert, a hand-rolled banner, or a console.log. As with every other primitive, we style Base UI's toast; we do not build our own toast system. The legacy kit useToast / Toaster is non-compliant and is being removed — do not use it in v2.

The kit exposes a styled Toaster (a Toast.Portal + Toast.Viewport rendering styled Toast.Roots, keyed off each toast's type). Mount Base UI's Toast.Provider and the Toaster once at the app root; everything else queues toasts through Base UI's manager.

// app root — Base UI provider + the kit's styled viewport, mounted once
import { Toast } from "@base-ui/react/toast";
import { Toaster } from "@probo/ui";

<Toast.Provider>
  <App />
  <Toaster />
</Toast.Provider>

Queue a toast with Toast.useToastManager().add(...) — the same API Base UI exposes. Use type to drive the styled variant.

import { Toast } from "@base-ui/react/toast";

function CreateMeasureButton() {
  const toast = Toast.useToastManager();
  const { t } = useTranslation();
  const [createMeasure] = useMutation<CreateMeasureMutation>(createMeasureMutation);

  function onCreate() {
    createMeasure({
      variables: { input, connections: [connectionId] },
      onCompleted() {
        toast.add({ title: t("measures.created"), type: "success" });
      },
      onError(error) {
        toast.add({
          title: t("common.error"),
          description: formatError(t("measures.createFailed"), error as GraphQLError),
          type: "error",
        });
      },
    });
  }
  // …
}

For code outside the React tree, create a global manager with Toast.createToastManager() and pass it to Toast.Provider via toastManager — still the same renderer.

Choose toast vs. inline by where the message belongs:

  • Toast — the result of an action not tied to a specific field: a successful save, a delete, an unexpected mutation failure.
  • Inline — validation tied to a field or region: render it in Field.Error (see forms.md) or a section's error UI (see error-handling.md), not a toast.
// Bad — the legacy kit hook (removed in v2)
const { toast } = useToast();
toast({ title: "Saved", variant: "success" });

// Bad — field validation surfaced as a toast (belongs inline on the field)
toast.add({ title: "Name is required", type: "error" });

// Bad — browser alert / ad-hoc UI for feedback
alert("Saved!");

Empty states

A *List (or any collection region) renders an empty state when it has no items — never a blank gap. Empty states are part of the component, not an afterthought, and follow the *Empty suffix when extracted (see react-components.md).

An empty state has: an icon (phosphor — never emoji), a short heading, optional one-line guidance, and, when the user can act, the primary call to action (gated by permission — see permissions.md).

// Good — collection renders its own empty state
{measures.length === 0
  ? <MeasuresEmpty canCreate={canCreate} />
  : measures.map((m) => <MeasureListItem key={m.id} measureKey={m} />)}

Distinguish empty (no data) from loading (*Skeleton) from error (*Error) — they are three different states, not one.

Accessibility

Base UI primitives ship correct roles, focus management, and keyboard interaction — do not re-implement or override them. Our job is to keep that behavior intact while styling:

  • Keep accessible labels: every control has a visible label or an aria-label; icon-only buttons (Button icon={…}) require an aria-label.
  • Don't strip aria-* / role that primitives set, and don't trap or override focus the primitive manages.
  • Convey state with more than color (e.g. an icon + text alongside a red-* tone), so meaning survives for color-blind users — the token contrast guarantees cover text legibility, not state encoding.
  • Use semantic elements (<button>, <a>, <nav>, headings) — see the Button / ButtonLink / ButtonAnchor / Link / Anchor split.