You copy a dropdown menu out of the shadcn/ui docs into your project. It compiles, it opens, and it is invisible: white text on a white panel, with a menu item that turns into a grey slab on hover.
Nothing in the component is broken. It is asking for variables your project does not publish.
Why it happens
A shadcn component contains no colours. It contains names:
<div className="bg-popover text-popover-foreground border-border shadow-md">
<div className="focus:bg-accent focus:text-accent-foreground">Profile</div>
</div>
Those utilities exist only if your Tailwind theme defines --color-popover,
--color-popover-foreground, --color-border, --color-accent and
--color-accent-foreground. In Tailwind v4 an unknown utility is not an error:
it simply is not generated, so the element renders with no background at all and
inherits whatever is behind it.
If your project named the same concepts differently (canvas, ink,
hairline, surface-card) every one of those classes is a no-op.
The fix: publish both sets of names
You do not have to choose. A colour token is a variable, and a variable can have two names pointing at one value.
Declare the palette once, using shadcn's names as the raw layer:
:root {
--background: #ffffff;
--foreground: #09090b;
--card: #ffffff;
--card-foreground: #09090b;
--popover: #ffffff;
--popover-foreground: #09090b;
--primary: #18181b;
--primary-foreground: #fafafa;
--destructive: #dc2626;
--border: #e4e4e7;
--input: #e4e4e7;
--ring: #18181b;
}
Then map both naming systems onto it in one theme block:
@theme inline {
/* your names */
--color-canvas: var(--background);
--color-ink: var(--foreground);
--color-hairline: var(--border);
/* shadcn's names, same values */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary-foreground: var(--primary-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
}
Now bg-canvas and bg-background are the same rule with two names, and a
pasted component works untouched. Use @theme inline rather than @theme: it
emits background-color: var(--background) instead of copying today's hex, so
the utilities follow a dark-mode swap.
The cost is one block of aliases, written once. The benefit is that the entire shadcn catalogue becomes copy-paste for your project, forever.
The names you cannot bridge
Sometimes a name is already taken and means something else in your system. Two are common:
muted. Upstream, --muted is a light grey surface and
--muted-foreground is the secondary text colour. Plenty of design systems use
muted for the text colour directly, so text-muted already exists and means
#71717a. You cannot have bg-muted be a pale surface and text-muted be a mid
grey: one variable, one value.
accent. Upstream, --accent is the hover surface for menu items. Many
systems use accent for their one saturated brand colour. Again, one variable.
You have to pick, and the right answer is almost always keep your own meaning, because your codebase already has dozens of usages and the paste has one or two. Then write the translation down where a person doing the paste will see it:
| Upstream class | Here | Paste this instead |
|---|---|---|
bg-muted | muted is the muted text colour | bg-surface-card |
bg-accent / text-accent-foreground | accent is the saturated brand colour | bg-surface-card / text-ink |
Two classes, and they cluster: they appear in menu items, command palettes and list rows. A grep on the way in catches them:
grep -nE "\b(bg|text|border)-(muted|accent)\b" src/components/ui/dropdown-menu.tsx
Note the word boundary. text-muted-foreground and bg-accent-soft are
different tokens and are fine; only the bare names collide.
Other things a paste brings with it
cva and tailwind-merge. Upstream examples often use
class-variance-authority and cn(). If your repo deliberately carries neither,
rewrite the variant object as a plain lookup:
const variants: Record<Variant, string> = {
default: "bg-primary text-primary-foreground",
outline: "border border-input bg-background",
};
It is the same information with one fewer dependency, and it stays readable up to about five variants.
Radix packages. The interactive components genuinely depend on
@radix-ui/react-*. Add the package properly rather than deleting the import
and hand-rolling a focus trap: accessible menus are harder than they look.
Hard-coded values in examples. Docs examples sometimes carry an arbitrary value or a palette class from the demo page. A lint that fails on raw colours catches these at the door, which is a good reason to have one.
How to know it worked
Render the pasted component and check three things: it has a background, its border is visible, and its hover state is a surface rather than a saturated block. Then check it again in dark mode if you have one: a bridged token follows the mode, a missed one does not, which makes dark mode an unusually good detector for classes you forgot to translate.