Custom Components
Most ERP screens compose entirely from AppShell primitives. When you hit a gap, work through this decision tree before building anything:
Decision tree
- Can you compose existing AppShell primitives? A "card with a metric and a trend arrow" is
MetricCard, orCardplus a lucide icon — not a new component. Compose first, and check the component docs for what already exists before concluding there is a gap. - If not, build it locally under
src/components/<name>/, conforming to the rules below.
If a local custom proves reusable across several apps, it is a candidate to contribute upstream into AppShell — a separate, contributor workflow (see
CONTRIBUTING.md/ theadd-componentskill), not something to do from a consuming app.
Conformance rules (non-negotiable for any custom component)
- Tokens only. No hex literals, no magic px values, no hand-rolled shadows. Every visual property maps to a token from the Styling & Theming catalog — and only to a token that actually exists there.
- Base UI data-attribute pattern for state. Expose
data-*attributes that reflect internal state; never style off React props alone. A custom toggle exposesdata-checked; a custom step indicator exposesdata-active,data-completed, etc. - Compose AppShell primitives inside. If the custom needs a button, use
Button— not raw<button>. Same forInput,Badge,Card. The custom's job is composition, not reinvention. - Document with a
README.mdin the component folder listing: purpose, props, tokens used, and a brief justification for why this can't be composed from existing AppShell primitives. - Match accessibility behavior of the closest AppShell equivalent — focus management, ARIA roles/attributes, keyboard interactions. Reach for Base UI primitives if the behavior is non-trivial.
Example skeleton for a local custom component:
tsx
// src/components/StepIndicator/index.tsx
import { Check } from "lucide-react";
type Props = { steps: string[]; current: number };
export function StepIndicator({ steps, current }: Props) {
return (
<ol className="flex gap-2">
{steps.map((label, i) => (
<li
key={label}
data-active={i === current ? "" : undefined}
data-completed={i < current ? "" : undefined}
className="
flex items-center gap-2 rounded-md px-3 py-2
bg-muted text-sm text-muted-foreground
data-[active]:bg-primary data-[active]:text-primary-foreground
data-[completed]:text-status-completed
"
>
{i < current && <Check className="size-4" />}
{label}
</li>
))}
</ol>
);
}Notice: every value is a real token, data-* attributes carry state, no hex literals, and the icon comes from lucide-react rather than a nonexistent Icon component.