Mert Özen Further With Every Line
Frontend CSS Properties: A Hands-On Guide — #02

Text Styling: the text-decoration Family, text-underline-offset, text-shadow, letter-spacing, text-indent and vertical-align

Mert Özen Nov 15, 2026 13 min 8 views
Text Styling: the text-decoration Family, text-underline-offset, text-shadow, letter-spacing, text-indent and vertical-align

A readable link underline is written with longhands (text-decoration-thickness, text-underline-offset in em), letter-spacing is given in em or percent, and an inline icon is aligned to the digits with vertical-align. On Bookstore's catalog we measure, in Chromium and WebKit, how the text-decoration shorthand resets the thickness, how em is inherited as px, text-transform with lang="tr", the gap under an img and initial-letter support.

Version note: This post follows the CSS Text Module Level 3/4 and CSS Text Decoration Level 4 specifications and the November 2026 state of Baseline (web-features 3.46.0); the examples were run in Chromium 158 and WebKit 27.2 (Playwright 1.65), and Firefox support comes from web-features and MDN browser-compat-data 8.1.9.

Guess First

In the previous post we picked the font for Bookstore, the online bookstore used as the shared example across this site's series. Now we style the text itself. We gave the "New" label in the catalog its letter spacing in em, and we wrote a thick underline for the links in the book titles:

.badge, .badge-pct, .badge-none { font-size: 12px; text-transform: uppercase; }
.badge { letter-spacing: 0.1em; }
.badge-pct { letter-spacing: 10%; }
.big { font-size: 24px; }

.link { text-decoration-thickness: 2px; text-underline-offset: 0.2em; }
.link.reset { text-decoration: underline; }
<p class="badge">New <span class="big">New</span></p>
<p class="badge-pct">New <span class="big">New</span></p>
<p class="badge-none"><span class="big">New</span></p>

<p><a class="link" href="#">Dune</a> <a class="link reset" href="#">Emma</a></p>

In the first two paragraphs a 24px span sits inside a 12px label. What is the span's letter-spacing: 1.2px or 2.4px? Does the result change when we write 10%? On the last line .link.reset first gets text-decoration-thickness: 2px, then the text-decoration: underline shorthand. How thick is Emma's underline drawn?

text-decoration is a shorthand with four longhands: text-decoration-line (underline, overline, line-through), text-decoration-style (solid, double, dotted, dashed, wavy), text-decoration-color and text-decoration-thickness. Two more properties are not part of the shorthand: text-underline-offset, which sets the line's distance from the text, and text-decoration-skip-ink, which decides whether the line breaks around the descenders of letters such as g, p and y. I measured the lab in both engines:

node scripts/02-text.mjs
--- chromium
.link            thickness 2px  offset 3.2px
.link.reset      thickness auto  offset 3.2px
hover: thickness 3px, box 95.5x19 → 95.5x19
--- webkit
.link            thickness 2px  offset 3.2px
.link.reset      thickness auto  offset 3.2px
hover: thickness 3px, box 95.5x18.1796875 → 95.5x18.1796875

The answer to the opening question: Emma's thickness is auto, so the 2px is gone. A shorthand resets the longhands you leave out to their initial values, and text-decoration-thickness is now part of the shorthand. text-underline-offset is not part of the shorthand, so it stayed at 3.2px. One detail: before Safari 26.2, Safari's shorthand did not include the thickness, so the same code keeps the 2px line in older Safari and resets it in the other engines. For a consistent result, write longhands in link styles.

Links in Bookstore's base styles look like this:

a {
    color: var(--color-link);
    text-decoration-line: underline;
    text-decoration-thickness: 1px;
    text-underline-offset: 0.2em;
    text-decoration-skip-ink: auto;
}
a:hover {
    /* thicker, not a new color: the box does not move */
    text-decoration-thickness: 2px;
}

text-underline-offset: 0.2em moves the line a little away from the letters and keeps Turkish letters with marks below them, such as ç and ş, from touching it. Giving the value in em makes the gap grow with the font; for a 16px font it was computed as 3.2px in the measurement. text-decoration-skip-ink: auto is already the default, but writing it explicitly in the base styles makes the intent clear.

On hover we thicken the line instead of changing the color. In the measurement the thickness rose to 3px after hover, but the link's box stayed the same in both engines: the line is not added to the text's box, it is only painted over it. So changing the thickness does not shift the layout. On the catalog card the titles are links, but we want them to look like the surrounding text. Instead of removing the underline, we set its color to the border color and bring it to currentColor on hover:

.book-card h2 a {
    color: inherit;
    text-decoration-color: var(--color-border);
}
.book-card h2 a:hover { text-decoration-color: currentColor; }
node scripts/02-pages.mjs
--- chromium
badge: font-size 12px, letter-spacing 0.96px
price icon center -6.0px, digits center -5.8px (from baseline)
initial-letter: true, -webkit-initial-letter: false
card link hover: 2px rgb(31, 41, 55), box moved: false
--- webkit
badge: font-size 12px, letter-spacing 0.96px
price icon center -6.0px, digits center -5.8px (from baseline)
initial-letter: false, -webkit-initial-letter: true
card link hover: 2px rgb(31, 41, 55), box moved: false

The last lines show the card link getting a 2px thickness and the text color on hover while its box does not move. A link should not be distinguished from text by color alone; the underline is a cue that also works for users who cannot see the color difference. The details of that rule and the contrast thresholds are the subject of the Accessibility (a11y) series.

letter-spacing and word-spacing: em, px or Percent

In small uppercase labels the letters look glued together, so a little letter-spacing is added. The measurement for the opening question:

.badge            font-size 12px  letter-spacing 1.2px
.badge-pct        font-size 12px  letter-spacing 10%
.badge .big       font-size 24px  letter-spacing 1.2px   width 61.1px
.badge-pct .big   font-size 24px  letter-spacing 10%     width 64.7px
.badge-none .big  font-size 24px  letter-spacing normal  width 57.5px

0.1em is computed against the label's own 12px font as 1.2px, and that px value is inherited by the child. The 24px span also gets 1.2px: its width is only 3.6px more than without letter spacing (57.5px). What is inherited is not the em but the computed length. 10%, on the other hand, is inherited as a percentage and each element resolves it against its own font size: the span gets 2.4px and its width grows by 7.2px. According to browser-compat-data, percentage letter-spacing has been available in all three engines since Chrome 145 (February 2026), Firefox 133 and Safari 17.4; web-features 3.46 does not list this sub-feature as a separate Baseline entry.

A practical rule: do not give letter-spacing in px. When the font grows the spacing stays the same and almost disappears on large headings, while it is too much on small text. Using em on the label itself is enough; in a component with nested font sizes, prefer a percentage. Bookstore's label:

.badge {
    display: inline-block;
    padding: 0.125rem 0.5rem;
    border-radius: 0.25rem;
    background: var(--color-accent);
    color: #ffffff;
    font-size: 0.75rem;
    font-weight: 700;
    /* em, not px: grows with the badge's own font-size */
    letter-spacing: 0.08em;
    text-transform: uppercase;
}

On the catalog page the label's computed value is 0.96px (8% of 12px). word-spacing follows the same rules but is rarely needed in everyday interfaces; its most common use is keeping the space between words in a letter-spaced uppercase heading from blending into the letter spacing.

text-transform, text-align and text-indent

The markup of the label says "New"; text-transform: uppercase turns it into capitals. The transformation follows the element's lang value:

<span lang="en" style="text-transform: uppercase">yeni kitap</span> /
<span lang="tr" style="text-transform: uppercase">yeni kitap</span> /
<span lang="tr" style="text-transform: lowercase">ISBN İADE</span>
lang=en uppercase  → YENI KITAP
lang=tr uppercase  → YENİ KİTAP
lang=tr lowercase  → ısbn iade

With lang="en" the word "yeni" becomes YENI, with lang="tr" it correctly becomes YENİ. The same rule applies in the other direction: on a Turkish page lowercase turns the abbreviation ISBN into ısbn. A Turkish interface needs <html lang="tr">; do not apply text-transform to parts that must not change, such as codes, abbreviations and brand names, or give those parts their own lang value. When to use uppercase at all is discussed in the Typography post of the UI/UX Design Principles series; here we only look at the mechanism. The measuring script reads the text with innerText because innerText returns it with text-transform applied; textContent returns the text in the markup.

Using the logical start/end instead of the physical left/right in text-align makes the alignment flip by itself when the page is translated into a right-to-left language. With text-align: justify the last line is still aligned to the start; text-align-last controls that. text-indent indents the first line, and the hanging keyword inverts it:

.indent { inline-size: 16em; text-indent: 2em; }
.indent.hanging { text-indent: 2em hanging; }
indent           line 1 starts at 32.0px, line 2 at 0.0px
indent hanging   line 1 starts at 0.0px, line 2 at 32.0px

With a normal indent the first line starts 32px (2em) in. With hanging the first line stays at the edge and the following lines are indented; useful for bibliographies and a book's imprint details. The hanging and each-line keywords are Baseline newly available (March 2026, with Chrome 146).

text-shadow: Syntax, Multiple Shadows and Limits

text-shadow consists of a horizontal and a vertical offset, an optional blur and a color; unlike box-shadow it has no spread and no inset. Several shadows can be written separated by commas, and the first one is painted on top:

.shadow { text-shadow: 0 1px 2px rgb(0 0 0 / 0.6), 0 0 8px #fbbf24; }
text-shadow: rgba(0, 0, 0, 0.6) 0px 1px 2px, rgb(251, 191, 36) 0px 0px 8px

In the computed value the color moves to the front and lengths are converted to px; this is the format you see in DevTools. The first shadow puts a thin dark shadow under the letters to separate the text from the background, the second is a blurred yellow "glow" with zero offset. The real way to make text readable on an image banner is to add a dark gradient layer to the background; we will do that for the campaign banner in the fourth post. Do not use text-shadow to reach sufficient contrast: the WCAG contrast ratio is defined only by the text and background colors, most automated tools either ignore the shadow or mark the result as uncertain, and a thin shadow makes text even blurrier on low-resolution screens.

vertical-align: Icon Alignment and the Gap Under img

vertical-align applies only to inline-level and table-cell boxes; it is not for centering a block (that is what Flexbox and Grid are for, posts 05 and 06 of the CSS3 series). On an inline icon or image, however, it is behind the two most common problems. The first is the unexplained gap under an svg or img inside a div:

.icon-box { inline-size: max-content; background: #eee; }
.icon-box svg { inline-size: 24px; block-size: 24px; }
.icon-box.block svg { display: block; }
.icon-box.middle svg { vertical-align: middle; }
--- chromium
icon-box         box height 31px (svg 24px)
icon-box block   box height 24px (svg 24px)
icon-box middle  box height 25.140625px (svg 24px)
--- webkit
icon-box         box height 30.40625px (svg 24px)
icon-box block   box height 24px (svg 24px)
icon-box middle  box height 25.71875px (svg 24px)

The box of the 24px icon is 31px tall. The reason is that an inline image sits on the text baseline by default: below it, room is reserved for the descenders of letters like g, p and y that could be on the same line. vertical-align: middle shrinks the gap but does not remove it (25.1px); when the image stands alone, the right fix is display: block. The rest of the image reset (max-inline-size, block-size: auto) is in this series' images post.

The second problem is the icon next to the price sitting above or below the text. I tried four values with a 20px font; the numbers are the distances of the icon's top and bottom edges from the baseline:

.price { font: 700 20px/1.5 system-ui, sans-serif; }
.price svg { inline-size: 1em; block-size: 1em; }
.price .baseline svg { vertical-align: baseline; }
.price .middle svg { vertical-align: middle; }
.price .text-bottom svg { vertical-align: text-bottom; }
.price .em svg { vertical-align: -0.125em; }
.probe { display: inline-block; inline-size: 0; block-size: 0; }
baseline         icon top -20.0px  bottom 0.0px from baseline
middle           icon top -15.3px  bottom 4.7px from baseline
text-bottom      icon top -16.0px  bottom 4.0px from baseline
em               icon top -17.5px  bottom 2.5px from baseline

With baseline the icon's bottom edge is on the baseline, so the icon reaches higher than the digits. middle aligns the icon's middle with a point half the x-height above the baseline; digits are taller than the x-height, so the icon ends up slightly low. text-bottom moves the bottom edge down to the lowest point of the font. -0.125em moves the 1em icon down by 2.5px and puts its middle 7.5px above the baseline. We used the same value on Bookstore's catalog card:

.price svg {
    inline-size: 1em;
    block-size: 1em;
    /* centers a 1em icon on the digits */
    vertical-align: -0.125em;
    fill: currentColor;
}

In the 02-pages.mjs output above, the icon's middle is 6.0px above the baseline and the visible middle of the digits "420" is 5.8px above it; a 0.2px difference. I read the height of the digits from the same font with the canvas measureText method. -0.125em is not right for every font; in this measurement it lands close to the middle because system-ui's digits are about 0.73em tall. With a different font, check the value again with the same measurement. In table cells, vertical-align: middle centers the cell content vertically as expected; table styles come in the last post of the series.

::first-letter and initial-letter

::first-letter selects the first letter of a block and accepts properties such as font, color and margin. We turn the first letter of the summary paragraph on the book detail page into a large initial:

.book-summary::first-letter {
    font-family: var(--font-display);
    font-weight: 700;
    color: var(--color-accent);
}
@supports (initial-letter: 2) {
    .book-summary::first-letter {
        initial-letter: 2;
        margin-inline-end: 0.5rem;
    }
}

initial-letter: 2 enlarges the letter to the height of two lines and sinks it into them; no manual float and font-size math is needed. But it is not Baseline: Chrome has had it unprefixed since 110, Safari supports it only as -webkit-initial-letter and partially, and Firefox does not have it. In the measurement Chromium accepted only the unprefixed value and WebKit only the prefixed one. That is why we put it inside @supports: a browser without support shows the first letter colored and bold without breaking the lines.

In Production

The properties in this family are cheap: text-decoration-thickness, text-decoration-color and text-shadow do not change the layout and only need a repaint. letter-spacing, word-spacing and text-transform change the width of the text and therefore trigger layout; animating them on hover makes lines shift. Measuring animations and choosing compositor-friendly properties is covered in post 13 of the CSS3 series.

text-transform changes only the presentation. How uppercased text is announced by a screen reader and which form ends up on the clipboard when copied depend on the browser and the screen reader. If you need to show a value (an ISBN, a coupon code) in capitals, produce it in capitals at the source instead of leaving it to CSS.

Visual decisions such as links and icon alignment look hard to test automatically, but a small Playwright test that reads computed values and box sizes, like the scripts in this post, catches a refactoring that moves a box on hover.

Common Pitfalls

First, giving letter-spacing in px. When the font grows, the spacing stays the same; giving it in em and then getting stuck with the inherited px value in a component with nested font sizes is the second form of the same mistake. As we measured, a percentage solves both.

Second, trying to rescue unreadable text with text-shadow. The contrast ratio is calculated from the text and background colors; the shadow is not part of it. Change the background or put a semi-transparent layer behind the text.

Third, removing the underline from links entirely and distinguishing them by color alone. Keeping the underline in a faint color, as we did on the card title, signals a link while staying visually light.

Fourth, adding a border-bottom or changing font-weight on hover. Both change the size of the box and push neighboring text; text-decoration-thickness and text-decoration-color do not.

Baseline Status

node scripts/baseline.mjs text-decoration text-decoration-skip-ink text-underline-offset letter-spacing text-shadow vertical-align text-transform text-align-last text-indent-hanging first-letter initial-letter
text-decoration            widely 2018-01-29  Chrome 1, Firefox 1, Safari 1, iOS 1
text-decoration-skip-ink   widely 2024-09-14  Chrome 64, Firefox 70, Safari 15.4, iOS 15.4
text-underline-offset      widely 2023-05-19  Chrome 87, Firefox 70, Safari 12.1, iOS 12.2
letter-spacing             widely 2018-01-29  Chrome 1, Firefox 1, Safari 1, iOS 1
text-shadow                widely 2018-01-29  Chrome 2, Firefox 3.5, Safari 1.1, iOS 1
vertical-align             widely 2018-01-29  Chrome 1, Firefox 1, Safari 1, iOS 1
text-transform             widely 2018-01-29  Chrome 1, Firefox 1, Safari 1, iOS 1
text-align-last            widely 2025-03-12  Chrome 47, Firefox 49, Safari 16, iOS 16
text-indent-hanging        newly 2026-03-13   Chrome 146, Firefox 121, Safari 15, iOS 15
first-letter               widely 2018-01-29  Chrome 1, Firefox 1, Safari 1, iOS 1
initial-letter             not Baseline       Chrome 110, Firefox -, Safari -, iOS -
FeatureStatusNote
text-decoration longhands, text-underline-offset, text-decoration-skip-inkWidely availableThe shorthand includes the thickness in Safari since 26.2
letter-spacing (length)Widely availablePercentage value: Chrome 145, Firefox 133, Safari 17.4
text-shadow, vertical-align, text-transformWidely availabletext-transform follows lang
text-align-lastWidely availableSince 2025-03
text-indent with hanging and each-lineNewly availableSince 2026-03
initial-letterNot BaselineChrome; Safari only with -webkit-; use inside @supports

A Small Experiment

★☆☆ Set the catalog label's letter-spacing to 8% and add a 16px span inside the label. Add a line to 02-pages.mjs that prints the span's computed value and measure it.

★★☆ Put the price icon next to a heading set in Fraunces (for example a favorite icon next to the h1 on the book detail page). Repeat the measurement from 02-pages.mjs for this heading and find out whether -0.125em is the right value for this font.

★★★ Write a second @supports block that also covers Safari's prefixed value for initial-letter, and verify in WebKit that the first letter sinks two lines deep by measuring the boxes of the letter and the paragraph.

In the next post we look at where and how text breaks: white-space and its longhands, the difference between overflow-wrap and word-break for long ISBNs and URLs, the state of hyphens for Turkish, text-wrap: balance and pretty, and the line clamp that cuts the book description at three lines.