Adding a component
Step-by-step recipe for wrapping a new shadcn primitive. Includes the template.
Audience: engineers (designers can skim).
This is the step-by-step for wrapping a new shadcn primitive into an UpNext wrapper. Follow it exactly — the pattern matters more than the cleverness of any individual file.
When to add one
When the team needs a UI primitive that doesn't exist yet in components/upnext/. Check the barrel export first — it's possible the wrapper already exists and just isn't being imported.
Don't wrap a shadcn primitive "preemptively just in case." Wrappers are committed surface area; every one is a maintenance promise. Wrap when you have an actual consumer.
The recipe
1. Install the shadcn primitive
npx shadcn@latest add <component> --cwd /Users/arshankhatri/test/design-library
This creates components/ui/<component>.tsx. Don't edit this file — it's the unowned base layer. Re-running the shadcn CLI will overwrite it.
2. Create the wrapper file
components/upnext/<component>.tsx. Use this template:
/**
* UpNext <Component> — single source of truth.
*
* USE THIS, NOT @/components/ui/<component>.
*
* <One-sentence summary of what the wrapper adds over the base.>
*
* State → token map:
* <state> <token used>
* <state> <token used>
*/
"use client"; // if the underlying primitive is interactive
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { <Primitive> as Shadcn<Primitive> } from "@/components/ui/<component>";
import { cn } from "@/lib/utils";
const componentVariants = cva(
// base classes
"...",
{
variants: {
variant: { /* lock UpNext's variant set here */ },
size: { /* lock UpNext's size set here */ },
},
defaultVariants: { variant: "...", size: "..." },
},
);
type Props = Omit<React.ComponentProps<typeof Shadcn<Primitive>>, "variant" | "size">
& VariantProps<typeof componentVariants>;
export function <Component>({ className, variant, size, ...props }: Props) {
return (
<Shadcn<Primitive>
data-upnext="<component>"
data-variant={variant}
data-size={size}
className={cn(componentVariants({ variant, size }), className)}
{...props}
/>
);
}
3. Wire every state to a token
Use the table from states-and-interaction.md. Specifically:
- Hover: the
-hovertoken (e.g.hover:bg-primary-hover) - Active/pressed: the
-activetoken - Focus visible:
focus-visible:ring-3 focus-visible:ring-ring/45 focus-visible:ring-offset-2 focus-visible:ring-offset-background - Disabled:
disabled:opacity-50 disabled:pointer-events-none - aria-invalid (if a form control):
aria-invalid:border-error aria-invalid:ring-3 aria-invalid:ring-error/25 - Scale on press (for buttons / actionable surfaces):
active:not-disabled:scale-[0.96]
4. Specific transitions only
Never transition: all. List the exact properties:
"transition-[background-color,border-color,color,scale] duration-[120ms]",
"[transition-timing-function:cubic-bezier(0.2,0,0,1)]",
The duration is 120ms for interactive transitions, 160ms for surface-level transitions on cards, 200ms+ for overlay enter/exit.
5. Tag the root
Every wrapper sets data-upnext="<name>" on its root element. Two reasons:
- Easy to grep in browser inspector ("where does this style come from?")
- Future tooling (Figma plugin scrapes, screenshot diffing, codemods) can use these attributes as anchors
6. Re-export from the barrel
Add the new file to components/upnext/index.ts:
export * from "./<component>";
7. Add a showcase to the playground
Find the right tab — app/library/<tab>/page.tsx — and add a section that demonstrates every variant and every state of your new component. Show the resolved token underneath each state (the make-interfaces-feel-better polish convention we're using). Use realistic recruiting mock data, not Lorem Ipsum.
8. Update the docs (if the pattern changed)
If your component does something the pattern doesn't already cover (e.g. a new state convention, a new addon shape), add a note here. If it just follows the existing pattern, no doc change needed.
Worked example — the Button wrapper
Read components/upnext/button.tsx end to end. Specifically notice:
- The variant list (
primary | secondary | outline | ghost | destructive | success | warning | info | link) is defined here, not imported from shadcn. shadcn's defaults are not ours. - Every variant explicitly names the resting bg, hover bg, active bg.
- The base class string lists exactly which properties transition (
bg, border, color, box-shadow, scale). active:not-disabled:scale-[0.96]— the:not-disabledguard prevents the press effect on disabled buttons (otherwise they'd visibly compress when you click them, which feels wrong).- The
data-icon="inline-start"/"inline-end"slots are kept from shadcn — they're an accessibility-friendly way to pad around icons. - The
loadingprop renders a spinner and forcesdisabled.
Use this file as the template for any other interactive wrapper.
Don'ts
- Don't fork shadcn primitives into the wrapper. If shadcn ships an update that fixes an accessibility bug, you want it. Wrap, don't copy.
- Don't expose shadcn variant names. Our
Buttonacceptsvariant="primary", notvariant="default". We translate at the wrapper layer. - Don't accept a
styleprop that takes arbitrary CSS. If you need an escape hatch, acceptclassNameand let consumers override with our utility classes. Arbitrary styles defeat the token system. - Don't add new variants without a designer asking for them. New variants are commitments. Adding "tertiary" because it might be useful is how you end up with seventeen ways to be secondary.