Mert Özen Further With Every Line
Frontend Tailwind CSS — #08

Everyday Utility Families 2: Typography, Color, Border, Shadow, Effect, Transform and Form Utilities, the typography and forms Plugins

Mert Özen Nov 15, 2026 13 min 5 views
Everyday Utility Families 2: Typography, Color, Border, Shadow, Effect, Transform and Form Utilities, the typography and forms Plugins

In Tailwind v4, border and ring default to currentColor, ring is 1px, scale/rotate/translate write to the individual transform properties, and once @tailwindcss/forms is added accent-* no longer paints the checkbox. We measure the hover shadow and cover scale on Bookstore's book card, grayscale for out-of-stock books, native controls versus the forms plugin on the checkout form, and the prose class on a CMS body in Playwright.

Version note: The examples in this post were run with Tailwind CSS 4.3.5, @tailwindcss/typography 0.5.20, @tailwindcss/forms 0.5.11, Node.js 24 and Playwright 1.65 (Chromium 158).

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, asks about "Gift wrap" on its checkout form with a checkbox. We gave it accent-indigo-600 so that it uses the brand's indigo:

<label class="flex items-center gap-2">
    <input id="gift" type="checkbox" class="size-4 accent-indigo-600">
    <span>Gift wrap</span>
</label>

Because forms look different from browser to browser, someone on the team added the @tailwindcss/forms plugin to the project; the checkbox's classes weren't touched. When the user ticks the box, what color is its inside?

This is the last post of the series, and it covers the second half of the everyday utility families, the visual side: typography, color and the opacity modifier, gradients, borders, shadows, rings and outlines, effects and transforms, form and interaction utilities. At the end come two official plugins: @tailwindcss/typography for HTML from the CMS and @tailwindcss/forms for forms. As in the previous post, we look at the CSS each class generates and read the result from the computed style in Playwright. The properties themselves are covered in the CSS Properties: A Hands-On Guide series.

Typography: Size, Line Height and text-balance

node scripts/04-selectors.mjs 'text-sm' 'text-sm/6' 'leading-tight' 'text-balance'
text-sm
  .text-sm {
    font-size: var(--text-sm);
    line-height: var(--tw-leading, var(--text-sm--line-height));
  }

text-sm/6
  .text-sm\/6 {
    font-size: var(--text-sm);
    line-height: calc(var(--spacing) * 6);
  }

leading-tight
  .leading-tight {
    --tw-leading: var(--leading-tight);
    line-height: var(--leading-tight);
  }

text-balance
  .text-balance {
    text-wrap: balance;
  }

The text-* sizes don't just set font-size; each size also comes with a matching line-height. In the theme post we saw that double-dash variables like --text-sm--line-height carry that pairing. leading-* overrides the value through --tw-leading, so the order of text-sm leading-tight doesn't matter. text-sm/6 sets both in one class and takes the line height from the spacing scale. tracking-* sets letter spacing, font-* the weight and family, and tabular-nums makes digits the same width, so numbers in a price column line up.

We used the truncation utilities in the previous post: truncate for one line, line-clamp-2 for two. text-balance does something different for headings. We wrote the Institutional Orders campaign headline twice in a 320px band, once with text-balance:

<h2 id="promo-plain" class="w-80 text-2xl font-bold">Institutional orders get a 10% discount</h2>
<h2 id="promo-balance" class="mt-4 w-80 text-2xl font-bold text-balance text-shadow-sm">Institutional orders get a 10% discount</h2>
npm run build:08
node scripts/08-visual.mjs
#promo-plain       text-wrap wrap    line widths 272px / 153px
#promo-balance     text-wrap balance line widths 212px / 213px

With normal wrapping the first line fills up and the second is left with a short tail; text-balance made the lines equal. Browsers limit this to text of a few lines, so use it only for headings; for paragraphs text-pretty applies a cheaper rule and mainly tries to avoid a single word on the last line. The text-shadow-sm on the same headline arrived in v4.1; it separates white text on a colored background a little:

text-shadow-sm
  .text-shadow-sm {
    text-shadow: 0px 1px 0px var(--tw-text-shadow-color, rgb(0 0 0 / 0.075)), 0px 1px 1px var(--tw-text-shadow-color, rgb(0 0 0 / 0.075)), 0px 2px 2px var(--tw-text-shadow-color, rgb(0 0 0 / 0.075));
  }

The --tw-text-shadow-color variable lets a color class like text-shadow-indigo-900 change the color without redefining the shadow. The CSS side of line breaking and text shadows is in the Text Styles and Line Breaking and Overflow posts of the CSS Properties series.

Color, the Opacity Modifier and Gradients

Color classes read the theme variable (bg-indigo-600 → var(--color-indigo-600)). v3's bg-opacity-* is gone; transparency is written next to the color with a /:

bg-indigo-600/80
  .bg-indigo-600\/80 {
    background-color: color-mix(in srgb, oklch(51.1% 0.262 276.966) 80%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      background-color: color-mix(in oklab, var(--color-indigo-600) 80%, transparent);
    }
  }

The opacity modifier mixes the color with transparent using color-mix(). The first line is a fallback that uses the color itself rather than the variable; the real rule is inside @supports. The "Out of stock" strip on the cover of an out-of-stock book is bg-slate-900/70:

emma overlay       background oklab(0.208 -0.00310889 -0.0418848 / 0.7)

This is the difference from opacity-*: opacity-70 would make the element transparent together with the white text inside it; the modifier only affects the background. Gradients also come with new names: bg-linear-to-r instead of bg-gradient-to-r, plus bg-radial and bg-conic:

bg-linear-to-r
  .bg-linear-to-r {
    --tw-gradient-position: to right;
    @supports (background-image: linear-gradient(in lab, red, red)) {
      --tw-gradient-position: to right in oklab;
    }
    background-image: linear-gradient(var(--tw-gradient-stops));
  }

The campaign band is bg-linear-to-r from-indigo-600 to-sky-500. In a supporting browser the transition is computed in the oklab space, so the washed-out, greyish middle that sRGB can produce between two saturated colors shows up less. You can also write angles like bg-linear-45 and an interpolation modifier such as /oklch. The mask-* utilities from v4.1 apply the same gradient logic to masks: the short blurb on the book card ends with a fade over its lower half thanks to max-h-16 overflow-hidden mask-b-from-50%. The details of gradients and masks are in the Backgrounds and Gradients and the clip-path, mask and shape-outside posts of the CSS Properties series.

Borders, Shadows, Rings and Outlines

This is the family where habits from v3 go wrong most often, and the culprit is the defaults. We measured three elements:

<p id="border-default" class="border p-2 text-slate-600">Paperback</p>
<p id="ring-default" class="ring p-2 text-indigo-600">Hardcover</p>
<button id="btn-hidden" type="button" class="rounded bg-white px-3 py-1 outline-hidden">outline-hidden</button>
border             color oklch(0.446 0.043 257.281)  border-color oklch(0.446 0.043 257.281)  width 1px
ring               box-shadow oklch(0.511 0.262 276.966) 0px 0px 0px 1px
forced-colors none   outline-none: none  outline-hidden: none
forced-colors active outline-none: none  outline-hidden: solid 2px

border sets no color; Preflight doesn't set a border color, so currentColor is used and the border becomes the same slate-600 as the text. In v3 the default was gray-200. ring is 1px and also currentColor; for v3's 3px blue ring you now write ring-3 ring-blue-500. Keep in mind that the ring is drawn with box-shadow: if the element has a shadow-* as well, the two are combined as layers of the same property and don't override each other.

The outline lines matter. outline-none really sets outline-style: none. outline-hidden looks the same on a normal screen, but in forced colors mode, such as Windows high contrast, it turns into a 2px outline; we saw this in Playwright with forcedColors: "active". If you replace the focus ring with your own style, use outline-hidden; someone using high contrast still sees the focus.

The shadow scale shifted by one step in v4: v3's shadow-sm is now shadow-xs, and v3's shadow is shadow-sm; the same goes for rounded and blur. We measured this with the upgrade tool in the maintenance post. inset-shadow-* is a separate family for inner shadows and can be combined with an outer shadow on the same element. The CSS side of borders, shadows and focus rings is in the Border, border-radius, outline and box-shadow post of the CSS Properties series.

Effects and Transforms: The Book Card and the Out-of-Stock State

The card in the catalog grows its shadow on hover and zooms the cover slightly. For an out-of-stock book the cover is grey and faded:

<article id="card-dune" class="group overflow-hidden rounded-lg bg-white shadow-sm transition-shadow duration-300 hover:shadow-lg">
    <div class="overflow-hidden">
        <div class="aspect-2/3 bg-amber-200 transition-transform duration-300 group-hover:scale-105 motion-reduce:transition-none"></div>
    </div>
    ...
</article>
<article id="card-emma" data-stock="out" class="group overflow-hidden rounded-lg bg-white shadow-sm">
    <div class="relative overflow-hidden">
        <div class="aspect-2/3 bg-sky-200 group-data-[stock=out]:opacity-60 group-data-[stock=out]:grayscale"></div>
        <span class="absolute inset-x-0 bottom-0 bg-slate-900/70 py-1 text-center text-sm text-white">Out of stock</span>
    </div>
    ...
</article>
dune before hover  shadow rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2px -1px  cover scale none
dune after hover   shadow rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.1) 0px 4px 6px -4px  cover scale 1.05
emma cover         filter grayscale(1)  opacity 0.6
reduced motion     cover transition-property none

The stock state isn't a class in the template but data-stock="out"; with group-data-[...] from the variants post the cover turns grey based on that attribute. The overflow-hidden wrapper around the cover keeps the growing cover from spilling out of the card.

The scale line in the measurement stands out: the value is in the scale property, not in transform. In v4, scale-*, rotate-* and translate-* write to the individual transform properties:

scale-105
  .scale-105 {
    --tw-scale-x: 105%;
    --tw-scale-y: 105%;
    --tw-scale-z: 105%;
    scale: var(--tw-scale-x) var(--tw-scale-y);
  }

rotate-y-12
  .rotate-y-12 {
    --tw-rotate-y: rotateY(12deg);
    transform: var(--tw-rotate-x, ) var(--tw-rotate-y, ) var(--tw-rotate-z, ) var(--tw-skew-x, ) var(--tw-skew-y, );
  }

The practical result is that transition-transform now covers the list transform, translate, scale, rotate, and that a transform rule of your own doesn't override scale-105. The 3D utilities (rotate-x-*, rotate-y-*, perspective-*, transform-3d) use the transform property; for a "book spine" effect that turns the cover slightly, the depth comes from a utility like perspective-midrange on the parent. The details of transforms are in the Transforms post of the CSS Properties series, and filter and backdrop-filter in the filter, backdrop-filter and Blend Modes post.

motion-reduce:transition-none turns off the transition for users who have reduce motion enabled in their operating system; the measurement shows transition-property none. The cover still grows, only without animation. To turn animation on only where it's wanted, you can write the opposite: motion-safe:transition-transform.

Form and Interaction Utilities: Checkout

We first built the checkout form without the plugin:

<input id="email" type="email" placeholder="ada@example.com" class="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 caret-indigo-600 placeholder:text-slate-400 focus-visible:outline-2 focus-visible:outline-indigo-600">
<textarea id="note" rows="1" class="mt-1 block w-full field-sizing-content rounded-md border border-slate-300 px-3 py-2"></textarea>
<input id="gift" type="checkbox" class="size-4 accent-indigo-600">
<input id="po" type="file" accept="application/pdf" class="mt-1 block text-sm file:me-4 file:rounded file:border-0 file:bg-indigo-50 file:px-3 file:py-1 file:text-indigo-700">
<button id="place-order" type="submit" disabled class="cursor-pointer rounded bg-indigo-600 py-2 text-white select-none disabled:cursor-not-allowed disabled:opacity-50">Place order</button>
node scripts/08-forms.mjs
checkout.out.css
  email     appearance auto, font-size 16px, placeholder oklch(0.704 0.04 256.788)
  select    appearance auto, padding-right 12px, background-image none
  checkbox  appearance auto, 16px, background rgba(0, 0, 0, 0)
  checked   appearance auto, 16px, background rgba(0, 0, 0, 0), accent-color oklch(0.511 0.262 276.966)
  invoice   checked, background rgba(0, 0, 0, 0), accent-color oklch(0.511 0.262 276.966)
  textarea  field-sizing content, height 42px -> 90px
  file      button background oklch(0.962 0.018 272.314), margin-inline-end 16px
  submit    disabled, cursor not-allowed, opacity 0.5

The cheapest way for native controls is accent-*: the browser draws the checkbox and paints its checked state in the brand color. caret-* sets the color of the text cursor, placeholder: the color of the placeholder. field-sizing-content grows the delivery note field with its content: after three lines of text it went from 42px to 90px, without JavaScript. The file: variant styles the ::file-selector-button pseudo-element; we matched the button an institutional customer uses to upload a purchase order PDF to the brand without writing a separate component. In v4 Preflight gives buttons cursor: default; if you want the pointer, write cursor-pointer explicitly, and cursor-not-allowed for the disabled state.

The small interaction utilities are plain CSS too: select-none (user-select: none), pointer-events-none, touch-pan-x (touch-action). The cover carousel uses scroll snap:

<ul id="carousel" class="mt-8 flex w-80 snap-x snap-mandatory gap-4 overflow-x-auto scrollbar-thin">
    <li class="h-36 w-24 shrink-0 snap-start rounded bg-amber-200"></li>
    ...
</ul>
carousel           scrolled 70px, scrollLeft after snap 112px, scrollbar-width thin

After a 70px scroll the list settled at the start of the second cover (96px cover + 16px gap). scrollbar-thin arrived in v4.3. All of these properties are covered in the Form Controls and Interaction Properties and the Overflow and Scroll Behavior posts of the CSS Properties series.

When Do You Need @tailwindcss/forms?

In v4 the plugin is loaded from the CSS file:

@import "tailwindcss" source(none);
@source "./checkout.html";
@plugin "@tailwindcss/forms";

We measured the same form with this stylesheet:

checkout-forms.out.css
  email     appearance none, font-size 16px, placeholder oklch(0.704 0.04 256.788)
  select    appearance none, padding-right 12px, background-image url("data:image/svg+xml,%3csvg
  checkbox  appearance none, 16px, background rgb(255, 255, 255)
  checked   appearance none, 16px, background oklch(0.546 0.245 262.881), accent-color oklch(0.511 0.262 276.966)
  invoice   checked, background oklch(0.511 0.262 276.966), accent-color oklch(0.511 0.262 276.966)

The textarea, file button and submit lines came out the same as without the plugin, so only the lines that changed are shown. The answer to the guess is in the checked line: the ticked box is oklch(0.546 0.245 262.881), which is the plugin's default blue-600. The plugin sets appearance: none and draws the box itself; since the browser no longer draws it, accent-color is computed but paints nothing. The plugin gives the checked box's background currentColor, so you change the color with text-indigo-600. The box in the invoice line has both classes (text-indigo-600 accent-indigo-600) and is indigo with both stylesheets; if you decide to remove the plugin, the form doesn't break.

The second detail is in the select line. The plugin adds an arrow image and padding-right: 2.5rem for it, but the measured value is 12px: our px-3 won. The plugin's styles go into the base layer, and thanks to the layer order every utility overrides them. That's usually what you want, but if you don't give the right padding back on the select with pe-10, a long option text can run under the arrow.

When do you need it? If the design wants inputs to look identical across browsers down to the pixel, and the checkbox and radio themselves need to be drawn, the plugin does that cheaply. If the brand color and a few borders are enough, native controls with accent-*, border-*, rounded-* and appearance-none will do; the second path was enough for Bookstore's checkout. You can also use the plugin without global styles: @plugin "@tailwindcss/forms" { strategy: "class"; } only generates classes like form-input, form-select and form-checkbox, which suits new screens when you don't want to touch existing forms.

@tailwindcss/typography: prose for the CMS Body

In the first post of the series we saw that HTML from the CMS is left unstyled after Preflight: you can't add classes to the elements inside the body. The typography plugin solves this with one class:

@import "tailwindcss" source(none);
@source "./index.html";
@source "./cms.html";
@plugin "@tailwindcss/typography";
<article id="body-prose" class="prose prose-slate prose-a:text-indigo-600 dark:prose-invert">
    <h2>Why Middlemarch still matters</h2>
    <p>George Eliot's novel follows a whole town. Read it slowly, and read the <a href="#notes">notes</a>.</p>
    <ul>
        <li>Penguin Classics edition</li>
        <li>Oxford World's Classics edition</li>
    </ul>
    <pre><code>isbn: 9780141439549</code></pre>
    <div class="not-prose">
        <button id="add-to-cart" type="button" class="rounded bg-indigo-600 px-3 py-1 text-white">Add to cart</button>
    </div>
</article>
node scripts/08-prose.mjs
#body-plain
  article   max-width none, font-size 16px, line-height 24px
  h2        font-size 16px, weight 400, margin-bottom 0px
  ul        list-style none, padding-inline-start 0px
  a         color rgb(0, 0, 0), text-decoration none
  pre       background rgba(0, 0, 0, 0), padding 0px
#body-prose
  article   max-width 655.078px, font-size 16px, line-height 28px
  h2        font-size 24px, weight 700, margin-bottom 24px
  ul        list-style disc, padding-inline-start 26px
  a         color oklch(0.511 0.262 276.966), text-decoration underline
  pre       background oklch(0.279 0.041 260.031), padding 12px 16px
not-prose button  font-size 16px, margin-top 0px
dark scheme       prose p color oklch(0.869 0.022 252.894), plain p color rgb(0, 0, 0)

With prose the same HTML became a readable article: the heading is 24px and bold, the list has bullets, the link is underlined and indigo, the code block sits on a dark background and the body is 65ch wide. prose-slate matches the grey tone to the project's palette, and element modifiers like prose-a: change a single element type (in v4 the state variant goes last: prose-a:hover:text-indigo-500). dark:prose-invert switched the text to a light color in the dark theme. If you need to put a component inside the body, not-prose takes it out of the plugin's rules; the add-to-cart button stayed plain. If you don't want the width limit, add max-w-none.

The cost is CSS size. For the same cms.html the minified CSS is 4.5 kB without the plugin and 19.8 kB with it (1.6 kB to 3.6 kB with gzip).

This site's blog body comes from the CMS as well; instead of the plugin, the site uses a single hand-written body class, because the font, the heading spacing and the code blocks are tuned to the site's own design. Both paths rest on the same idea: styling the body under one class with element selectors. If a ready-made typography is good enough, the plugin is less code; if the design specifies every detail, writing your own class can take less time than overriding the plugin's defaults one by one.

In Production

The visual utilities are the family that multiplies most with variants: hover:, focus-visible:, dark: and group-* turn the same color into four rules. If you track CSS size, look for growth here first.

For the opacity modifier and gradients Tailwind generates an @supports fallback, as we saw in the measured CSS. Newer properties such as text-wrap: balance, field-sizing and scrollbar-* get no such fallback; as the compatibility page says, check their support in your target browsers separately. In Bookstore they degrade harmlessly: the headline wraps normally, the delivery note field doesn't grow but stays usable. Don't rely on them where functionality would be lost.

Test forced colors mode and the reduce motion setting. A two-line check in Playwright with emulateMedia({ forcedColors: "active" }) and reducedMotion: "reduce", as in 08-visual.mjs, catches outline-none and forgotten motion-reduce: classes.

Common Pitfalls

The first is the one from the guess: using accent-* with @tailwindcss/forms. The plugin draws the control itself, so the color is set with text-*.

The second is v3 muscle memory: writing shadow-sm and expecting v3's thin shadow, rounded and expecting v3's value, ring and expecting a 3px blue ring, border and expecting a light grey border. All four differ in v4; reading the rename table from the maintenance post next to the measurements in this post makes the difference clear.

The third is hiding the focus ring with outline-none. On a normal screen it looks the same as outline-hidden, but in forced colors mode the focus disappears completely.

The fourth is mixing up opacity-* and the / modifier. Had we given the "Out of stock" strip opacity-70, the white text would have faded too.

The fifth is forgetting that plugins and utilities collide: the right padding the forms plugin gives the select is gone as soon as you write px-*.

A Small Experiment

★☆☆ Make the campaign headline in 08/index.html long enough for three lines, run npm run build:08 and node scripts/08-visual.mjs, and look at how text-balance distributes the line widths.

★★☆ Add pe-10 to the checkout select with the plugin enabled and run 08-forms.mjs so that it shows padding-right is 40px. Then rebuild with @plugin "@tailwindcss/forms" { strategy: "class"; } and measure which controls change their appearance.

★★★ Style the body in cms.html without the plugin, with your own @utility or a class in @layer components (h2, ul, a and pre are enough). Extend 08-prose.mjs for that class and compare the CSS size with the plugin version.

The series ends here. We started with what utility-first solves and what it costs; then came setup, tokens with @theme, variants, Angular and ASP.NET Core integration, maintenance and versions, and in the last two posts the utility families you write every day. What they all share is that every class has a readable line of CSS behind it. When you're unsure about a class, look at the CSS it generates with a small script like 04-selectors.mjs and measure the result in the browser; that's the most reliable way to use Tailwind well.