Skip to main content
ASoc
Tutorial

Styling React Components: An Inline-Style Census of 91 Components

Four ways to style React, and the constraint that ranks them: CSS-in-JS needs a client boundary. Plus every inline style in this codebase, categorised.

The ASoc Team10 min read

There are four ways to style a React component — a global stylesheet, CSS Modules, CSS-in-JS, and utility classes — and in an App Router codebase one constraint decides between them: CSS-in-JS runs during render, so every styled component has to be a Client Component. This storefront styles 91 components with utilities plus exactly 20 inline style objects. Here is the census, and the rule they follow.

The four options, and the one question that ranks them

Global CSSCSS ModulesCSS-in-JSUtility classes
Works in a Server ComponentYesYesNo — needs a client boundaryYes
Styles from runtime valuesNoNoYes, nativelyOnly via style or arbitrary values
Runtime costNoneNoneSerialization + injection per renderNone
Dead-style removalManualPer-modulePer-componentCompiler drops unused utilities
Where a style livesFar from the markupOne file awayIn the componentOn the element
Naming burdenHighMediumLowNone

Every row matters at some point; the first is the one that decides the architecture, because it is the one you cannot work around.

The Server Component constraint, from the docs

Next.js is explicit about it. From the CSS-in-JS guide shipped inside the framework itself (node_modules/next/dist/docs/01-app/02-guides/css-in-js.md, Next 16.2.9), the supported libraries — styled-components, styled-jsx, MUI, Chakra, Panda, StyleX, vanilla-extract and the rest — are supported "in Client Components in the app directory". Emotion is still listed as working on support. And enabling any of them is "a three-step opt-in process": a style registry to collect the rules of a render, the useServerInsertedHTML hook to inject them before the content that uses them, and a Client Component that wraps the app with that registry during server rendering.

Translate that into a codebase count. 24 of this site's 91 components carry "use client". Choosing styled-components would not have added a dependency; it would have moved that boundary to every component that renders a styled element, which is all of them. The cost is not the library's kilobytes. It is that styling stops being a build-time concern and becomes a render-time one, and render-time concerns belong to the client.

This is the same reasoning that shapes the rest of the component tree, and the general version of it — why the boundary is about the module graph a component drags across it, not the component itself — is in Server Components vs Client Components.

The census

grep -rn 'style={{' src --include=*.tsx returns 50 hits in this repository. They fall into three groups:

WhereCountWhy
src/app/**/opengraph-image.tsx (2 files)17Satori renders these to PNG and does not run Tailwind — inline is the only option
src/data/*.tsx13Content files that embed icon and image JSX
src/components/** (91 components)20The subject of this post

Zero styling dependencies back them: package.json lists 13 runtime dependencies and none of them is a styling library. tailwindcss is a devDependency, because it compiles away.

Broken down, the 20 in components are:

CategoryCountExample
Runtime-computed values4TemplateGallery, FaqItem, PreviewModal ×2
Long static CSS not worth a class6Carousel, Trust, HeroBackground ×4
color: "transparent" port artifacts6Hero ×2, Testimonials, AvatarGroup, PaymentInfo, UseCaseCard
Honeypot display: "none"2NewsletterForm, ContactForm
Leftover offsets from the port2Header (top: "10px", twice)

Two of those five rows are legitimate, one is defensible, and two are debris. That distribution is worth more than any style guide, because it is what a real codebase looks like after a year.

The rule: inline style is for what the build cannot know

The four runtime-computed cases are the ones no class can express, because the value does not exist until the component renders:

// TemplateGallery — the slide position, from state
<div style={{ transform: `translateX(-${index * 100}%)` }}>

// FaqItem — animating to content height without measuring it
<div
  className="grid transition-[grid-template-rows] duration-300"
  style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
>

// PreviewModal — an iframe laid out at a device width, then CSS-scaled to fit
<div style={{ width: frameWidth ? frameWidth * scale : "100%" }}>

Note what these have in common with each other and with nothing else in the file: a number computed at render time. FaqItem is the clearest example of the pattern — the animation and the transition live in classes, and the single value that cannot be enumerated ahead of time lives in style. Two of Tailwind's escape hatches would fail here: grid-rows-[1fr] written dynamically is unreachable to the compiler (it scans source text, so a class name assembled from a variable does not exist at build time), and hard-coding both states doubles the markup for one value.

The second legitimate group is long static CSS that would be unreadable as a class:

// Carousel — the marquee's edge fade
<div style={{
  maskImage: "linear-gradient(to right, transparent, black 8%, black 92%, transparent)",
}}>

That is a judgement call, not a rule. Written as [mask-image:linear-gradient(to_right,transparent,black_8%,black_92%,transparent)] it works and it sorts and it is horrible to read. Four more like it live in HeroBackground, each a multi-layer radial gradient. The honest framing: an arbitrary-value class buys you variant support (hover:, md:, dark:); if you do not need a variant, the inline style is the more readable of two equal options.

The honeypot pair is the defensible one:

<div style={{ display: "none" }} aria-hidden="true">
  <label htmlFor="company">Company</label>

hidden would be shorter. The inline style survives a stylesheet that fails to load, and a spam trap that becomes visible to humans when CSS 404s is a form that traps the wrong people.

What the audit found: six vestigial styles

Six components render an image with style={{ color: "transparent" }}. None of them meant to. It is what next/image emits, carried over verbatim when these sections were ported to plain <img> elements — UseCaseCard still has the full eight-property block, position, inset, size and all, that a fill image used to need.

On a loaded image the declaration does nothing: there is no text to colour. It does exactly one thing, in exactly one state — when the image fails to load, the browser renders the alt text in the element's colour, and transparent makes it invisible. So the failure state of a broken product image is a blank rectangle rather than the words describing it.

We have left them in place for now, and the reason is worth being explicit about, because "found a defect, shipped a fix" is the more satisfying ending. The trade runs both ways: removing the declaration makes alt text visible when an image is genuinely broken, and also makes it flash into view during every slow image load. One is a rare failure, the other is a common one. That is a design decision about the failure state of a product page, not a lint fix, and this codebase's rule is that ported section markup does not change on a whim. It is now recorded rather than invisible, which is the part that was missing.

The two top: "10px" offsets in Header have no such defence. They are port debris and top-2.5 is the same pixel.

Where the design tokens actually live

None of the above is where a Tailwind project's styling system lives. That is src/app/globals.css, whose @theme block declares the fonts, the primary scale from --color-primary-25 to --color-primary-950, the grey scale, and the semantic tokens — and those compile to real CSS custom properties, which is why themes can be swapped at runtime without a rebuild. Two things follow that are covered elsewhere rather than repeated here: what redeclaring a default colour name overrides, in Tailwind colors, and how the same variables make per-tenant theming a runtime problem, in multi-tenant theming with Tailwind v4.

The practical division of labour that has held up across 91 components:

  1. Tokens in @theme — anything a designer would name.
  2. Utility classes on the element — everything static, including responsive and dark variants.
  3. Inline style — only values computed at render time.
  4. A component — the moment the same class string appears three times. Not @apply, which recreates the naming problem utilities removed.

Mistakes and how they show up

MistakeHow it shows upFix
Assembling class names from variablesStyle silently missing in production, fine in devEnumerate full class strings, or use style for the dynamic value
Adding a CSS-in-JS library to an App Router project"use client" spreads through the tree; the server render loses its styles without a registryKeep styling build-time, or accept the registry and the boundary
Reaching for @apply to shorten markupA second naming system on top of the one you removedExtract a component instead
Inline styles for static valuesUnreachable by hover:, md: or dark: variantsClass for anything that has a variant
Copying next/image output into plain <img>Vestigial declarations like color: transparent that hide alt textAudit what the port actually needed
Assuming Tailwind classes work in an OG imageSatori renders no stylesheet; the PNG comes out unstyledInline styles in opengraph-image.tsx, by design
Sorting classes by handDiff noise and duplicate utilities nobody noticesprettier-plugin-tailwindcss

Frequently asked questions

Can I use styled-components with React Server Components? Not in a Server Component. Next.js's own guide lists it as supported "in Client Components", behind a three-step opt-in: a style registry, useServerInsertedHTML, and a client wrapper around the app. It works — it just makes styling a client-side concern.

Is the style prop bad for performance? Not meaningfully at this scale. A new object identity every render can defeat memoization in a hot list, which is why the four uses here are on single elements rather than mapped rows. The real argument against style is not speed, it is that inline declarations cannot express media queries, pseudo-classes or dark mode.

How do I set a CSS custom property from React? Pass it as a key on the style object — style={{ "--brand": color } as React.CSSProperties}. React forwards any ---prefixed key straight through; the cast exists only because the type definitions do not know about arbitrary properties.

Do these numbers apply to a template I buy? As a method, yes. Run the same grep against any React codebase and the categories fall out the same way — the interesting number is never the total, it is how many of them are runtime values rather than debris.

Templates in this post

ASoc Pip is a forex-trading marketing site built around a live rates and portfolio dashboard preview, with audience tabs, a comparison pricing table and an academy blog. ASoc Quest is a dark-theme games-storefront landing page — a featured-title hero, a weekly-deals carousel and top-seller grids — which is a useful reference for how far a token-driven dark palette carries. ASoc Rally is an AI CRM marketing site with a pipeline-overview hero, an integrations grid, a three-tier pricing table and an FAQ accordion.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial8 min read

Supabase SQL Editor: This Schema Has Never Been Edited Through It

Studio's SQL Editor is built for one-off queries. Every schema change here shipped instead as one of 8 reviewed migration files — what each tool is actually for.

Read more