How to Make a “Text Stroke” (Outline) Effect

Bold outlined text grabs attention without shouting. In this tutorial you will build a crisp “text stroke” (outline) effect that works across modern browsers. You will learn the WebKit text stroke approach for perfect edges, a resilient text-shadow fallback for Firefox and legacy contexts, and a flexible pattern for combining a stroked outline with solid or gradient fills. By the end, you will have a production-ready snippet you can drop into a hero, banner, or logo.

Why Text Stroke Matters

An outline turns any headline into a graphic element without exporting images. It scales cleanly at any size, stays selectable and searchable, and adapts to theming with a few color variables. A CSS stroke is faster to iterate on than switching SVG assets, and avoids layout shifts from image loading. You also gain control to animate width, glow, and color on hover. When paired with simple geometric badges or callouts, such as a circular count or a small caret, it can guide the eye and improve hierarchy. If you want a quick accent to sit next to a stroked headline, try this guide on how to make a circle with CSS, and for a small pointer under a label, this one covers how to make a triangle up with CSS. For wider hero banners, this pattern also pairs well with a ribbon-style heading; see how to make a ribbon banner with CSS.

Prerequisites

You do not need a framework for this. A few core skills make the process smoother, and we will use them throughout the steps.

  • Basic HTML
  • CSS custom properties
  • CSS pseudo-elements (::before / ::after)

Step 1: The HTML Structure

The markup keeps things simple: a wrapper, a demo section, and three headings that showcase different stroke styles. The data-text attribute feeds the pseudo-element for the gradient version, letting us duplicate the same string without repeating it manually. This helps avoid accidental content drift.

<!-- HTML -->
<main class="wrap">
  <h1 class="page-title">CSS Text Stroke / Outline</h1>

  <section class="examples">
    <h2 class="title title--outline" data-text="Outline Only">Outline Only</h2>

    <h2 class="title title--outline-fill" data-text="Stroke + Fill">Stroke + Fill</h2>

    <h2 class="title title--gradient-stroke" data-text="Gradient Fill + Stroke">Gradient Fill + Stroke</h2>
  </section>
</main>

Step 2: The Basic CSS & Styling

Set up color variables, a stroke width variable, and some layout styles. The titles use relative positioning to anchor pseudo-elements later. Font sizing uses clamp to scale smoothly across screens. The default color for the titles is transparent so the outline can be seen clearly, and we will reintroduce the fill when needed.

/* CSS */
:root {
  --bg: #0f1226;
  --ink: #f5f7ff;
  --accent: #7aa0ff;
  --stroke: #ffffff;
  --stroke-width: 3px;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html, body {
  height: 100%;
}

body {
  margin: 0;
  background: radial-gradient(1200px 800px at 70% 20%, #1a1f3f 0%, #0f1226 60%, #0b0d1b 100%);
  color: var(--ink);
  font-family: ui-sans-serif, system-ui, Segoe UI, Roboto, Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji;
  line-height: 1.2;
}

.wrap {
  display: grid;
  place-items: center;
  min-height: 100%;
  padding: 3rem 1rem 5rem;
}

.page-title {
  font-size: clamp(1.2rem, 2vw + 1rem, 1.5rem);
  font-weight: 600;
  letter-spacing: 0.02em;
  opacity: 0.75;
  margin: 0 0 1rem;
}

.examples {
  display: grid;
  gap: 2.5rem;
  width: min(100%, 1000px);
  text-align: center;
}

.title {
  position: relative;
  margin: 0;
  font-weight: 900;
  letter-spacing: 0.02em;
  font-size: clamp(2.4rem, 8vw + 1rem, 7rem);
  line-height: 1;
  color: transparent; /* revealed by fill styles later */
  text-transform: uppercase;
  user-select: text;
}

Advanced Tip: Use CSS variables for theme colors and stroke width so you can remap the look per section or per component. For instance, a dark hero might use a thin light stroke, while a light panel could use a chunky dark stroke with the same code path.

Step 3: Building the WebKit Text Stroke

Chromium and WebKit engines support a native text stroke property. It draws a true vector outline around glyphs. We will set the fill to transparent for the “outline only” style and restore the fill for the “stroke + fill” variant. We pair this with a small piece of supports logic so browsers that know text stroke use it, and others will fall back to text-shadow in the next step.

/* CSS */
.title--outline {
  /* default: transparent fill so the outline sits on its own */
}

.title--outline-fill {
  /* we will reapply a fill color in the supports block */
}

/* Prefer the native text stroke when the engine offers it */
@supports (-webkit-text-stroke: 1px black) {
  .title--outline,
  .title--outline-fill {
    -webkit-text-stroke: var(--stroke-width) var(--stroke);
    -webkit-text-fill-color: transparent; /* outline visible */
    color: transparent;
  }

  /* Add the fill back for the filled variant */
  .title--outline-fill {
    -webkit-text-fill-color: var(--ink);
    color: var(--ink);
  }
}

How This Works (Code Breakdown)

We give both title variants the stroke via -webkit-text-stroke so they share the same outline width and color. Setting -webkit-text-fill-color: transparent removes the interior color, leaving only the edge. This produces the clean, poster-like “hollow” text.

For the stroke + fill version, we switch the fill back on with -webkit-text-fill-color: var(–ink). That value can be anything: a theme color, a gradient via background-clip text (shown later), or even a video if you are clipping a background.

The @supports block isolates the feature so we do not rely on JavaScript detection. Browsers that support -webkit-text-stroke will use this branch. Others will ignore it and stick to the fallback in Step 4. This yields crisp edges where available and still produces a solid outline everywhere else.

Step 4: Building the Cross-Browser Fallback

Firefox and some older engines do not draw a true text stroke. A dense ring of text shadows in eight directions can simulate one. It is a known technique and works well for display sizes. The idea is simple: draw small colored shadows around the glyph from several offsets so the result reads as a single outline.

/* CSS */
/* 1) Default: shadow-based stroke that works everywhere */
.title--outline,
.title--outline-fill,
.title--gradient-stroke::before {
  /* shadow stroke uses --w so we can tune thickness */
  --w: var(--stroke-width);
  text-shadow:
    calc(var(--w)) 0 0 var(--stroke),
    calc(var(--w) * -1) 0 0 var(--stroke),
    0 calc(var(--w)) 0 var(--stroke),
    0 calc(var(--w) * -1) 0 var(--stroke),
    calc(var(--w)) calc(var(--w)) 0 var(--stroke),
    calc(var(--w)) calc(var(--w) * -1) 0 var(--stroke),
    calc(var(--w) * -1) calc(var(--w)) 0 var(--stroke),
    calc(var(--w) * -1) calc(var(--w) * -1) 0 var(--stroke);
}

/* 2) When native stroke exists, stop drawing the shadow */
@supports (-webkit-text-stroke: 1px black) {
  .title--outline,
  .title--outline-fill,
  .title--gradient-stroke::before {
    text-shadow: none;
  }
}

How This Works (Code Breakdown)

The shadow stroke paints eight copies of the text in the stroke color, nudged by a small offset in the cardinal and diagonal directions. Think of it as a pixel ring around the text. For larger stroke widths, you can duplicate that list with a bigger –w value or add intermediate offsets such as 2px steps to smooth the ring further.

We attach the same shadow recipe to .title–gradient-stroke::before, which will serve as the outline for the gradient variant. The supports block cancels the shadow when -webkit-text-stroke is available, so the faster native path takes over. This pattern keeps the CSS predictable: fallback first, feature override second.

Advanced Techniques: Adding Gradient Fills, Hover Pulse, and Glow

The gradient version draws a stroked outline on a pseudo-element and a gradient fill on the real element. That separation gives you layering control: the fill can be transparent, a flat color, a gradient, or a pattern, while the outline remains crisp above it.

/* CSS */
/* Gradient fill on the element itself */
.title--gradient-stroke {
  background: linear-gradient(100deg, #ffe66d 0%, #ff5e5b 45%, #7aa0ff 100%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

/* The outline sits on a duplicated pseudo-element */
.title--gradient-stroke::before {
  content: attr(data-text);
  position: absolute;
  inset: 0;
  color: transparent; /* the outline itself provides the edge */
  pointer-events: none;
  /* shadow-based outline applied already in Step 4 */
}

/* When native stroke exists, upgrade the outline copy */
@supports (-webkit-text-stroke: 1px black) {
  .title--gradient-stroke::before {
    -webkit-text-stroke: var(--stroke-width) var(--stroke);
    -webkit-text-fill-color: transparent;
  }
}

/* Optional: a soft neon glow for emphasis */
.title--gradient-stroke:hover::before {
  filter: drop-shadow(0 0 8px rgba(122, 160, 255, 0.8));
}

/* Optional: animated pulse of the outline width */
@property --w {
  syntax: '<length>';
  inherits: false;
  initial-value: 3px;
}

.title--outline:hover,
.title--outline-fill:hover,
.title--gradient-stroke:hover::before {
  animation: pulse-stroke 800ms ease-in-out 0s 2 alternate;
}

@keyframes pulse-stroke {
  from { --w: var(--stroke-width); }
  to   { --w: calc(var(--stroke-width) * 1.8); }
}

/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
  .title--gradient-stroke:hover::before,
  .title--outline:hover,
  .title--outline-fill:hover {
    animation: none;
    filter: none;
  }
}

The gradient is applied with background-clip: text so the text becomes a window to the gradient. The outline is drawn by the pseudo-element which duplicates the exact same characters via content: attr(data-text). This avoids any tiny antialiasing seams you might see when you try to layer strokes and fills on the same node.

The glow is a single drop-shadow filter on hover. It highlights the headline without changing the fill. The pulse animation registers the –w variable as a typed length using @property. That enables a smooth transition between stroke widths with both the text-shadow fallback and the native stroke path, since both reference –w. The prefers-reduced-motion query turns off the effect for sensitive users.

Accessibility & Performance

Accessibility

Outlined text can fail contrast if the stroke and background are close in value. Pick a stroke color that meets contrast with the background because the thin edge carries the legibility. When you combine stroke and fill, test both the fill color and the stroke against the background so the text remains readable across images or gradients.

When you duplicate text in a pseudo-element, the extra copy is decorative. It should not add a second reading order item. Our approach keeps the pseudo-element inaccessible by default, since it is not part of the DOM tree, and pointer-events are off. If you choose to build a stroked icon that is purely decorative, give it aria-hidden=”true”. If the outlined text acts as a logo or brand mark, include a visible text alternative or aria-label on the link that holds it.

For animations, provide a calm default and honor prefers-reduced-motion: reduce. The snippet above disables the pulse and glow for those users.

Performance

The native -webkit-text-stroke path is fast. It draws a single outline per glyph. The text-shadow fallback is heavier because it paints several shadows. For a single large headline this is fine. For dozens of moving elements, keep the shadows static and avoid animating them. Filters and multiple shadows impact paint time; restrict glow to hover and limit the duration. Try to keep the shadow list short and prefer one interactive focal element per screen.

From Hollow Headlines to Flexible UI Elements

You now have a clean, layered approach to text outlines: native text stroke where available and a shadow fallback that works everywhere. You also learned how to combine an outline with flat or gradient fills, add a soft glow, and pulse the outline width with a typed CSS variable. Keep these pieces modular and you can drop them into a hero, a button, or a label with only a few variable tweaks. Now you have the tools to turn any headline into a sharp, scalable graphic element.

Leave a Comment