Skip to main content
ASoc
Tutorial

Convert CSS to Tailwind: Four Destinations, 133 Lines That Stayed CSS

Every declaration lands in one of four places. Measured on a 92-component port: 41 tokens, 96 arbitrary values, and 12 hex colors that duplicated a token.

The ASoc Team9 min read

To convert CSS to Tailwind, send each declaration to one of four destinations: a token in @theme, a built-in utility, an arbitrary value in square brackets, or a hand-written rule that stays CSS. Converters handle the second bucket well and the other three badly, which is why a conversion that compiles is usually only half finished.

That last sentence is the part the tool pages skip. This storefront is a pixel-faithful port of a hand-written CSS design into Tailwind v4 across 92 component files, and what survived the conversion is measurable: one 133-line src/app/globals.css, 41 design tokens, exactly 2 @apply declarations, and 162 arbitrary-value utilities spanning 96 distinct values. Every number and code block below is from a grep of this repository.

The four destinations

DestinationUse it whenLooks likeCount here
A token in @themeThe value is part of a scale you reuse--color-gray-400: #98a2b3;41 tokens
A built-in utilityA utility already means the declarationpadding: 1rem → p-4the bulk of 92 files
An arbitrary valueOne-off value with no token and no utilitymax-w-[870px]162 uses, 96 distinct
Stays CSSUtilities structurally cannot express it@keyframes, ::-webkit-scrollbar133 lines total

Sorting correctly into those four is the whole job. A converter can only do the second column, because the other three need to know which values are part of your system and which are accidents — and that is a question about your design, not about your CSS.

Destination 1: colors and scales become tokens, not classes

This is the bucket that decides whether the conversion was worth doing. In Tailwind v4 the configuration lives in CSS, so a palette converts into a @theme block rather than a JS file (where tailwind.config.js went covers that move):

@theme {
  --font-sans: var(--font-outfit), ui-sans-serif, system-ui, sans-serif;

  --color-primary: #465fff;
  --color-gray-100: #f2f4f7;
  --color-gray-400: #98a2b3;
  --color-gray-900: #101828;

  --color-text-color-tertiary: #98a2b3;
  --color-stroke-secondary: #f2f4f7;

  --breakpoint-2xsm: 375px;
}

Each line there generates utilities: --color-gray-400 is what makes text-gray-400 and bg-gray-400 exist, and --breakpoint-2xsm is what makes 2xsm:flex compile. The full scale is walked in 41 design tokens.

Here is the measured failure mode. Converting a hex to text-[#98A2B3] compiles, renders identically, and passes review. Grep this repo for arbitrary hex colors and you get 14 occurrences:

Arbitrary valueOccurrencesToken that already holds that hex
text-[#98A2B3]7--color-gray-400 / --color-text-color-tertiary
border-[#F2F4F7]3--color-gray-100 / --color-stroke-secondary
bg-[#101828]1--color-gray-900
bg-[#ECFDF3]1--color-success-50
bg-[#fbfcff]2none — genuinely off-scale

Twelve of fourteen duplicate a token that already exists in the same stylesheet. In this codebase they are retained deliberately — the house rule is a pixel-faithful port, so the source page's exact values stay put. In your own conversion they are the signal that the token pass is not finished, and they cost you the thing you converted for: change --color-gray-400 and seven text-[#98A2B3] call sites silently keep the old colour.

So run the hex audit as a separate pass, after the markup compiles:

grep -rhoE '(text|bg|border|from|to|fill|stroke)-\[#[0-9A-Fa-f]{3,8}\]' src --include=*.tsx \
  | sort | uniq -c | sort -rn

Anything in that list whose hex appears in your @theme block is a conversion you left half-done.

Destination 2: the mechanical part, which tools do fine

The direct mappings are real and boring, and this is where the npm converters and browser tools earn their keep:

CSSTailwind
display: flex; align-items: centerflex items-center
padding: 1rem 1.5rempx-6 py-4
border-radius: 9999pxrounded-full
font-weight: 500font-medium
transition-duration: 200msduration-200
outline: noneoutline-none

A real class list from src/components/molecules/FooterLinkColumn.tsx shows the shape of the output — six declarations, including a hover state that needed no second selector:

<a className="text-base font-medium text-[#98A2B3] duration-200 hover:text-white hover:underline">

Two notes that save time at this step. First, class order is irrelevant to the cascade, so do not hand-sort: prettier-plugin-tailwindcss does it, and in v4 it needs to be pointed at your stylesheet to know your tokens, which is the whole of .prettierrc.json here:

{
  "plugins": ["prettier-plugin-tailwindcss"],
  "tailwindStylesheet": "./src/app/globals.css"
}

Second, resist @apply. It is the obvious destination for a converted class and it rebuilds the problem you are leaving — a name you have to open another file to understand. This project has 2 @apply declarations, both setting document defaults where no element exists to put a className on:

@layer base {
  body {
    @apply bg-white font-sans text-base font-normal text-gray-700;
  }
  .dark body {
    @apply bg-gray-900 text-gray-300;
  }
}

That is the legitimate case. A converted card component is not.

Destination 3: arbitrary values, used on purpose

When a value is a genuine one-off, square brackets are the correct answer, not a failure. The 96 distinct arbitrary values here cluster into recognisable kinds:

9  leading-[1.2]           typographic ratio off the spacing scale
8  max-w-[870px]           hand-tuned measure for one text column
5  aspect-[530/330]        a specific image crop
3  z-[10000]               stacking above a third-party overlay
3  blur-[15px]             a decorative glow

Square brackets also reach whole properties and keyframes, which is how src/components/molecules/MarqueeRow.tsx drives the carousel:

<div className="flex shrink-0 animate-[marquee_40s_linear_infinite] items-center gap-6 pr-6 group-hover:[animation-play-state:paused] motion-reduce:animate-none">

animate-[marquee_40s_linear_infinite] references a @keyframes block that stayed in CSS, and [animation-play-state:paused] is the arbitrary-property syntax — a bare property:value in brackets, here composed with a group-hover: variant. Both are conversions a tool will not suggest, because neither has a utility name to find.

The rule worth keeping: a value used once is an arbitrary value, a value used three times is a missing token. max-w-[870px] appearing 8 times across this repo is exactly the kind of row that should have become a token and did not.

Destination 4: what has to stay CSS

Some declarations have no utility form, and the honest conversion leaves them as rules. All of this repo's hand-written CSS is here, and it is the complete list of reasons:

/* 1. Keyframes — no utility can declare an animation */
@keyframes marquee {
  from { transform: translateX(0); }
  to   { transform: translateX(-100%); }
}

/* 2. Vendor pseudo-elements utilities cannot target */
@layer utilities {
  .no-scrollbar::-webkit-scrollbar { display: none; }
  .no-scrollbar {
    -ms-overflow-style: none;
    scrollbar-width: none;
  }
}

/* 3. A responsive ladder that does not match the built-in scale */
.container {
  width: 100%;
  margin-inline: auto;
  padding-inline: 1rem;
}
@media (min-width: 1440px) {
  .container { padding-inline: 4rem; }
}

The .container rule is the interesting one. It carries seven media queries with max-widths and padding that do not line up with Tailwind's defaults, so converting it would mean repeating a long responsive class string at all 35 <Container> call sites. One class is the better conversion. The decision is about repetition, not purity — and the shape of what a utility codebase still writes by hand is covered in Tailwind vs. CSS.

Dark mode is a fourth case that converts into configuration rather than classes. A .dark selector strategy becomes one line:

@custom-variant dark (&:where(.dark, .dark *));

After that, every dark: variant in the markup works (Tailwind dark mode has the toggle itself).

The trap that costs an hour

Add a token to @theme, use the new utility, and watch it do nothing. Tailwind v4 under Turbopack does not hot-reload @theme changes — the dev server keeps the previously generated utility set, so bg-primary silently fails to generate while every pre-existing class keeps working. There is no error. Restart npm run dev after editing tokens.

This is the single most expensive thing about a conversion done in token order, because it makes a correct edit look like a wrong one and sends you editing the markup instead.

Troubleshooting

SymptomCauseFix
A new utility from a token you just added does nothingTurbopack did not regenerate the utility set from the edited @themeRestart npm run dev — no error is printed
Class works in dev, missing in productionThe class is built from a string at runtime, so the scanner never saw itWrite complete class names; map a variant to full strings instead of concatenating fragments
Converted colours drift from the palette after a token changeCall sites hold text-[#hex] instead of a token utilityRun the hex-vs-@theme grep above and replace the duplicates
The override you added loses to the class before itBoth utilities set the same property; order in the string does not decide itFix the source order or split base/variant cleanly — adding !important hides it
IntelliSense cannot see your tokensv4 tooling needs the stylesheet, not a config filePoint the editor and prettier-plugin-tailwindcss at globals.css (setup)
A custom breakpoint variant does not compileThe breakpoint is still a media query, not a tokenDeclare it in @theme as --breakpoint-* (breakpoints)

FAQ

Can I convert CSS to Tailwind automatically? Only the mechanical bucket. The npm packages and browser converters reliably translate standalone declaration blocks into utilities, and that genuinely is most of the volume. What none of them can decide is whether a hex belongs in @theme, whether a one-off stays in brackets, or whether a rule should not convert at all — those need your design system. Treat tool output as a first pass, then run the token audit by hand.

Should I delete my stylesheet when the conversion is done? No. Target a small one, not an empty one. This project kept 133 lines: the @theme block, one @custom-variant, the .container ladder, a @keyframes block, a ::-webkit-scrollbar rule, and two @apply lines for body. Everything else became utilities.

Is @apply the right way to convert an existing CSS class? Almost never. It reproduces the indirection you are converting away from — a name whose meaning lives in another file — while adding Tailwind's build step on top. Convert the class into a component that owns its className instead. Reserve @apply for elements you cannot put a class on, such as body.

What order should I convert in? Tokens first, then markup, then the hex audit. Getting @theme right before touching components means the utilities you need already exist, so you are not inventing arbitrary values that a token should have covered. If you are coming from v3 at the same time, do the version move first — the v4 migration changes where configuration lives, and converting into the old shape means converting twice.

Templates in this post

ASoc Till (a POS system landing page), ASoc Timbre (an AI voice generator template) and ASoc Uptime (a web hosting site) all ship on the converted stylesheet described above — one globals.css, no component-level CSS files, tokens instead of hex values.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For what a utility codebase still writes by hand, see Tailwind vs. CSS; for the token scale these utilities read from, see 41 design tokens; for the same conversion from a preprocessor, see Sass vs Tailwind.

Keep reading

Tutorial8 min read

What's Actually in a Data Dashboard? 59 Views, Counted

9 admin templates, 59 dashboard views, audited from the catalog — what a coded data dashboard actually contains versus a BI tool.

Read more