How to make a shadcn app not look like shadcn
Change the theme, not the screens. In a Tailwind v4 shadcn/ui app, set your own fonts with next/font and @theme inline, replace the colour tokens under :root and .dark, change --radius, adjust the type scale, then edit the Button and Card files directly, because you own that code.
Symptom
You notice it when you put your product next to a competitor's, or when someone says "oh, it's one of those". Nothing is broken. It just looks like nobody decided anything.
Why it happens
shadcn/ui is not a package you install and theme from the outside. The CLI copies component files into your project, and those files read a small set of semantic tokens: background, foreground, primary, muted, accent, border, input, ring and a few more. The tokens are CSS variables in your global stylesheet.
When an AI builder or the CLI sets up a project, it writes the default values for those tokens. In the default neutral theme, --primary is oklch(0.205 0 0), the greys have zero chroma, and --radius is 0.625rem. In the new-york style, the Card file ships with rounded-xl border shadow-sm. Tailwind's default font-sans is a system font stack, so nothing sets a typeface unless you do.
If nobody changes those values, every app built this way inherits the same look. The good news is that the same structure makes it easy to fix: change a few variables and every component follows. For the wider story of why this happens across tools, see why every AI-built app looks the same.
The fix
This is for a Next.js App Router app using Tailwind v4 and the current shadcn/ui setup, where the theme lives in app/globals.css. If your components.json has "cssVariables": false, the components were generated with hard-coded colour classes and the token approach below will not reach them. The shadcn/ui docs say that is an install-time choice: you would need to re-add the components.
1. Load a typeface on purpose
Pick one family for the interface and, if it suits the product, a second for headings. Load both with next/font, expose them as CSS variables, and map them into the theme.
// app/layout.tsx
import { Instrument_Sans, Fraunces } from "next/font/google"
import "./globals.css"
const sans = Instrument_Sans({
subsets: ["latin"],
display: "swap",
variable: "--font-instrument",
})
const display = Fraunces({
subsets: ["latin"],
display: "swap",
variable: "--font-fraunces",
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en-GB" className={`${sans.variable} ${display.variable} antialiased`}>
<body>{children}</body>
</html>
)
}Then, in app/globals.css, add the mapping to the @theme inline block that shadcn/ui already created. The inline option matters here: Tailwind's docs explain that without it, a theme variable that points at another variable can resolve to the wrong value.
/* app/globals.css: inside the existing @theme inline block */
@theme inline {
--font-sans: var(--font-instrument);
--font-display: var(--font-fraunces);
/* ...the existing --color-* and --radius-* lines stay as they are */
}font-sans now uses your typeface everywhere, and you get a new font-display utility for headings. next/font downloads Google fonts at build time and serves them from your own domain, so the browser never calls Google.
2. Replace the colour tokens
Every component reads its colours from pairs: a surface token and a -foreground token for the text on it. Change the values under :root and .dark and leave the names alone. These are example values for a calm, warm product with one deep green accent:
/* app/globals.css */
:root {
--radius: 0.375rem;
--background: oklch(0.99 0.004 85);
--foreground: oklch(0.2 0.01 85);
--card: oklch(1 0 0);
--card-foreground: oklch(0.2 0.01 85);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.01 85);
--primary: oklch(0.45 0.12 160);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.96 0.006 85);
--secondary-foreground: oklch(0.25 0.01 85);
--muted: oklch(0.96 0.006 85);
--muted-foreground: oklch(0.5 0.01 85);
--accent: oklch(0.95 0.02 160);
--accent-foreground: oklch(0.3 0.08 160);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.91 0.006 85);
--input: oklch(0.64 0.01 85);
--ring: oklch(0.45 0.12 160);
}
.dark {
--background: oklch(0.18 0.008 85);
--foreground: oklch(0.96 0.004 85);
--card: oklch(0.22 0.008 85);
--card-foreground: oklch(0.96 0.004 85);
--popover: oklch(0.22 0.008 85);
--popover-foreground: oklch(0.96 0.004 85);
--primary: oklch(0.72 0.13 160);
--primary-foreground: oklch(0.2 0.03 160);
--secondary: oklch(0.27 0.008 85);
--secondary-foreground: oklch(0.96 0.004 85);
--muted: oklch(0.27 0.008 85);
--muted-foreground: oklch(0.72 0.008 85);
--accent: oklch(0.3 0.04 160);
--accent-foreground: oklch(0.92 0.04 160);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(0.55 0.008 85);
--ring: oklch(0.72 0.13 160);
}
/* Keep your existing --chart-* and --sidebar-* tokens, or tune them the same way */Three things to notice. The neutrals have a tiny bit of chroma (0.004 to 0.01 at hue 85), which warms the greys without anyone noticing why. --accent is no longer grey, so hovered menu items and ghost buttons pick up the brand. And --input is much darker than the default oklch(0.922 0 0) in light mode, and solid rather than the default 30% white in dark mode.
That last one is about accessibility, not taste. WCAG 2.2 success criterion 1.4.11, Non-text Contrast, asks for a contrast ratio of at least 3:1 for the visual information needed to identify a component. If a thin border is the only thing showing where an input is, the default value is far too light for that: oklch(0.922 0 0) on white is 1.26:1. The example values above clear the bar. In light mode, --input is 3.27:1 against --background and 3.36:1 on a white card. In dark mode, oklch(0.55 0.008 85) is 3.88:1 against --background and 3.57:1 on --card, where the default 30% white would only reach about 2.7:1.
Text needs 4.5:1 under criterion 1.4.3. In these examples, --muted-foreground is 5.83:1 on the light background and 7.58:1 on the dark one, --primary-foreground on --primary is 6.56:1 in light mode and 7.67:1 in dark mode, and --success-foreground on --success is 4.99:1 and 7.12:1. I worked these out by converting each OKLCH value to sRGB and applying the WCAG relative luminance formula. Run your own values through a contrast checker before you ship, because a small change in lightness can drop a pair below the line.
To add a brand colour the components do not know about, the shadcn/ui docs give the pattern: define it under :root and .dark, then expose it in @theme inline.
:root {
--success: oklch(0.52 0.12 150);
--success-foreground: oklch(0.985 0 0);
}
.dark {
--success: oklch(0.7 0.14 150);
--success-foreground: oklch(0.2 0.03 150);
}
@theme inline {
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
}You can now use bg-success and text-success-foreground like any built-in colour.
3. Change the radius once
The shadcn/ui theme derives its whole radius scale from --radius: --radius-sm is calc(var(--radius) * 0.6), --radius-lg is var(--radius), and --radius-xl is calc(var(--radius) * 1.4). So the one line in step 2, --radius: 0.375rem, takes buttons from 8px to about 5px and cards from 14px to about 8px. A tighter radius suits a dense working tool. A softer one suits a consumer product. Pick one and let the scale do the rest.
4. Set the type scale and density
Most shadcn/ui components use text-sm, which Tailwind defines as 0.875rem with a line height of calc(1.25 / 0.875). For a data-heavy product you might want slightly smaller, tighter text. Tailwind v4 lets you override a size and its line height together in @theme:
/* app/globals.css */
@theme {
--text-sm: 0.8125rem;
--text-sm--line-height: 1.25rem;
--text-display: 2.5rem;
--text-display--line-height: 1.1;
--text-display--letter-spacing: -0.02em;
}That gives every text-sm element 13px type, and adds a text-display utility for page titles. Pair it with font-display from step 1 and headings stop looking like the docs site.
5. Edit the component files you actually own
Tokens cover colour, radius and type. Structure, like padding, shadows and default sizes, lives in the component files, and shadcn/ui is built so you edit them directly. For example, in the new-york style the Card is flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm. Other styles use different classes, so open your own components/ui/card.tsx first. For a flatter, denser product:
// components/ui/card.tsx
import * as React from "react"
import { cn } from "@/lib/utils" // match the utils alias in your components.json
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-4 rounded-lg border bg-card py-5 text-card-foreground",
className
)}
{...props}
/>
)
}The same goes for the Button. Its sizes live in the buttonVariants call made with cva. In the new-york style, if you want a slightly taller default button with more horizontal room, change the size.default entry from h-9 px-4 py-2 has-[>svg]:px-3 to something like h-10 px-5 has-[>svg]:px-4. Every button that uses the default size updates.
Make these changes in one place and resist the urge to override them with one-off classes on each screen. If you need a different look in one spot, add a variant.
6. Preview before you commit
The shadcn/ui docs now include shadcn/create, which lets you preview colours, radius, fonts and icons together and generate a preset for your project. It is a quick way to try a few directions before you edit globals.css by hand.
How to prevent it
The sameness creeps back in when new screens get built with one-off classes. A few habits stop that.
- Ban raw colour classes in app code. If a screen uses
bg-blue-500ortext-gray-600, it bypasses the theme. Use the semantic tokens, or add a new one. - Add variants, not overrides. A third button style belongs in
buttonVariants, not as a longclassNameon one button. - Tell the AI tool about your tokens. v0 can read your Tailwind config and
globals.css, and Lovable lets you save fonts, colours and style keywords to the project's instructions. Use them, so new generations start from your theme. - Keep structural changes modest if you still generate with v0. v0's docs warn that it is trained on the default shadcn/ui components and may struggle with heavy customisation. Theme tokens are safe; rewriting component internals is where generations start to drift.
- Write the decisions down. One page listing your fonts, colour roles, radius and spacing rules is a design system in miniature. Building a design system for your MVP covers how far to take it.
When to get help
The steps above change how the app looks. They do not change how it works: what is on each screen, what the user sees first, whether the dashboard leads with the right number. If your app has started to feel like a collection of generated screens rather than one product, that is a design problem more than a CSS one.
That is the work I do. I am a product designer who writes the code, so I set up the theme, rework the layouts and make the changes in your codebase in the same pass. You can see the approach on FindPopups, or start with the design guide for AI-built apps. If your dashboard is the screen that feels most generic, dashboard design for MVPs is the next read.
- shadcn/ui docs: Theming, checked 10 November 2026
- shadcn/ui docs: Tailwind v4, checked 10 November 2026
- shadcn/ui docs: components.json, checked 10 November 2026
- shadcn/ui docs: Card, checked 10 November 2026
- shadcn/ui docs: Button, checked 10 November 2026
- Tailwind CSS docs: Theme variables, checked 10 November 2026
- Tailwind CSS docs: font-size, checked 10 November 2026
- Next.js docs: Font module, checked 10 November 2026
- W3C: WCAG 2.2, 1.4.3 Contrast (Minimum) and 1.4.11 Non-text Contrast, checked 10 November 2026
- v0 docs: Design systems (legacy registry), checked 10 November 2026
- Lovable docs: Design guidance, checked 10 November 2026
Questions founders ask
Do I have to rewrite my components to change how shadcn/ui looks?
No. The components read semantic tokens such as primary, muted and border from CSS variables, so changing the variables in globals.css restyles every component at once. You only edit component files for structural changes like padding or shadows.
Can I change the shadcn/ui base colour after setup?
The base colour only decides which token values the CLI writes at init. After that the values live in your CSS file, so you can change them directly under :root and .dark.
Will customising shadcn/ui break v0 generations?
It can. v0's docs say it is trained on the default shadcn/ui implementations and may struggle with heavily customised components. Theme tokens are safer to change than component structure.

Ant
Product designer who builds MVPs, based in Nottingham. 10+ years designing digital products, and the only person at BlackFig. More about me