30 CSS Text Hover Effects16 / 30

CSS onlyMIT licensed

Typography Outline / Stroke Fill

Hollow type that pours full on hover — and it's ONE element, no duplicate layers: the glyphs are transparent with a 1.5px -webkit-text-stroke, and the fill is a background-image clipped to the text whose background-size grows 0%→100% from the baseline up. Because stroke and fill are separate paint channels on the same glyphs, they never misalign by a subpixel the way stacked-copy builds do. The wordmark fills bottom-up like poured metal; the nav links fill left-to-right like a gauge; the stroke itself warms color mid-pour.

Published

Live Demo
Try it

The code

<section class="cth-16" aria-label="Outline to fill text demo">
  <div class="cth-16__stage">
    <div class="cth-16__scene">
      <header class="cth-16__head">
        <p class="cth-16__eyebrow">Grade 900 &mdash; a display face in 5 weights</p>
        <span class="cth-16__chip">background-size: 100% 0% &rarr; 100% 100%</span>
      </header>
      <a class="cth-16__hollow" href="#cth16-top" id="cth16-top">FORGE</a>
      <nav class="cth-16__nav" aria-label="Foundry">
        <a class="cth-16__bar" href="#cth16-top">Trial fonts</a>
        <a class="cth-16__bar" href="#cth16-top">Licensing</a>
        <a class="cth-16__bar" href="#cth16-top">Specimens</a>
      </nav>
      <p class="cth-16__hint">Hover the wordmark: the fill pours up from the baseline through <code>background-clip:text</code> &mdash; one element, stroke and fill on separate paint channels.</p>
    </div>
  </div>
</section>
.cth-16,
.cth-16 *,
.cth-16 *::before,
.cth-16 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cth-16 {
  --bg: oklch(0.18 0.008 60);
  --ink: oklch(0.93 0.008 75);
  --mut: oklch(0.6 0.015 70);
  --heat1: oklch(0.72 0.18 45);
  --heat2: oklch(0.85 0.15 80);
  --line: oklch(0.32 0.012 65);
  font-family: 'Segoe UI',system-ui,sans-serif;
  color: var(--ink);
}

.cth-16__stage {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  background: radial-gradient(90% 70% at 50% 100%,oklch(0.22 0.015 55),var(--bg) 70%);
  padding: clamp(20px,4vw,56px);
  container: cth16/inline-size;
}

.cth-16__scene {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: clamp(20px,3.6cqi,32px);
  width: min(100%,760px);
}

.cth-16__head {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  flex-wrap: wrap;
  padding-bottom: 14px;
  border-bottom: 1px solid var(--line);
}

.cth-16__eyebrow {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--mut);
}

.cth-16__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  color: var(--heat2);
  border: 1px solid color-mix(in oklch,var(--heat2) 40%,transparent);
  border-radius: 99px;
  padding: 5px 11px;
}

.cth-16__hollow {
  display: inline-block;
  text-decoration: none;
  font-size: clamp(72px,17cqi,180px);
  font-weight: 900;
  letter-spacing: .01em;
  line-height: 1;
  padding: .08em .06em;
  color: transparent;
  -webkit-text-stroke: 1.5px color-mix(in oklch,var(--ink) 65%,transparent);
  background-image: linear-gradient(15deg,var(--heat1),var(--heat2));
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 100% 0%;
  -webkit-background-clip: text;
  background-clip: text;
  transition: background-size .6s cubic-bezier(.3,.85,.2,1),-webkit-text-stroke-color .6s ease;
}

.cth-16__hollow:hover,
.cth-16__hollow:focus-visible {
  background-size: 100% 100%;
  -webkit-text-stroke-color: color-mix(in oklch,var(--heat1) 60%,transparent);
}

.cth-16__hollow:focus-visible {
  outline: 2px solid var(--heat2);
  outline-offset: 10px;
  border-radius: 6px;
}

.cth-16__nav {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: clamp(10px,2.4cqi,20px);
}

.cth-16__bar {
  display: grid;
  place-items: center;
  min-height: 48px;
  padding: 0 24px;
  font-size: 15px;
  font-weight: 800;
  letter-spacing: .04em;
  text-transform: uppercase;
  text-decoration: none;
  border: 1.5px solid var(--line);
  border-radius: 10px;
  color: transparent;
  -webkit-text-stroke: 1px color-mix(in oklch,var(--ink) 70%,transparent);
  background-image: linear-gradient(90deg,var(--heat1),var(--heat2));
  background-repeat: no-repeat;
  background-position: 0 0;
  background-size: 0% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  transition: background-size .45s cubic-bezier(.3,.85,.2,1),border-color .3s;
}

.cth-16__bar:hover,
.cth-16__bar:focus-visible {
  background-size: 100% 100%;
  border-color: color-mix(in oklch,var(--heat1) 50%,var(--line));
}

.cth-16__bar:focus-visible {
  outline: 2px solid var(--heat2);
  outline-offset: 3px;
}

.cth-16__hint {
  font-size: 12.5px;
  line-height: 1.7;
  color: var(--mut);
  max-width: 56ch;
}

.cth-16__hint code {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: .9em;
  background: oklch(0.26 0.01 60);
  padding: 1px 6px;
  border-radius: 4px;
  color: var(--heat2);
}

@media (prefers-reduced-motion: reduce) {
  .cth-16 * {
    transition-duration: .01ms !important;
  }
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Text Hover Effect from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Typography Outline / Stroke Fill
Source: https://codefronts.com/motion/css-text-hover-effects/typography-outline-stroke-fill/

Hollow type that pours full on hover — and it's ONE element, no duplicate layers: the glyphs are transparent with a 1.5px -webkit-text-stroke, and the fill is a background-image clipped to the text whose background-size grows 0%→100% from the baseline up. Because stroke and fill are separate paint channels on the same glyphs, they never misalign by a subpixel the way stacked-copy builds do. The wordmark fills bottom-up like poured metal; the nav links fill left-to-right like a gauge; the stroke itself warms color mid-pour.
## HTML
```html
<section class="cth-16" aria-label="Outline to fill text demo">
  <div class="cth-16__stage">
    <div class="cth-16__scene">
      <header class="cth-16__head">
        <p class="cth-16__eyebrow">Grade 900 &mdash; a display face in 5 weights</p>
        <span class="cth-16__chip">background-size: 100% 0% &rarr; 100% 100%</span>
      </header>
      <a class="cth-16__hollow" href="#cth16-top" id="cth16-top">FORGE</a>
      <nav class="cth-16__nav" aria-label="Foundry">
        <a class="cth-16__bar" href="#cth16-top">Trial fonts</a>
        <a class="cth-16__bar" href="#cth16-top">Licensing</a>
        <a class="cth-16__bar" href="#cth16-top">Specimens</a>
      </nav>
      <p class="cth-16__hint">Hover the wordmark: the fill pours up from the baseline through <code>background-clip:text</code> &mdash; one element, stroke and fill on separate paint channels.</p>
    </div>
  </div>
</section>
```
## CSS
```css
.cth-16,
.cth-16 *,
.cth-16 *::before,
.cth-16 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cth-16 {
  --bg: oklch(0.18 0.008 60);
  --ink: oklch(0.93 0.008 75);
  --mut: oklch(0.6 0.015 70);
  --heat1: oklch(0.72 0.18 45);
  --heat2: oklch(0.85 0.15 80);
  --line: oklch(0.32 0.012 65);
  font-family: 'Segoe UI',system-ui,sans-serif;
  color: var(--ink);
}

.cth-16__stage {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  background: radial-gradient(90% 70% at 50% 100%,oklch(0.22 0.015 55),var(--bg) 70%);
  padding: clamp(20px,4vw,56px);
  container: cth16/inline-size;
}

.cth-16__scene {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: clamp(20px,3.6cqi,32px);
  width: min(100%,760px);
}

.cth-16__head {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  flex-wrap: wrap;
  padding-bottom: 14px;
  border-bottom: 1px solid var(--line);
}

.cth-16__eyebrow {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--mut);
}

.cth-16__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  color: var(--heat2);
  border: 1px solid color-mix(in oklch,var(--heat2) 40%,transparent);
  border-radius: 99px;
  padding: 5px 11px;
}

.cth-16__hollow {
  display: inline-block;
  text-decoration: none;
  font-size: clamp(72px,17cqi,180px);
  font-weight: 900;
  letter-spacing: .01em;
  line-height: 1;
  padding: .08em .06em;
  color: transparent;
  -webkit-text-stroke: 1.5px color-mix(in oklch,var(--ink) 65%,transparent);
  background-image: linear-gradient(15deg,var(--heat1),var(--heat2));
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 100% 0%;
  -webkit-background-clip: text;
  background-clip: text;
  transition: background-size .6s cubic-bezier(.3,.85,.2,1),-webkit-text-stroke-color .6s ease;
}

.cth-16__hollow:hover,
.cth-16__hollow:focus-visible {
  background-size: 100% 100%;
  -webkit-text-stroke-color: color-mix(in oklch,var(--heat1) 60%,transparent);
}

.cth-16__hollow:focus-visible {
  outline: 2px solid var(--heat2);
  outline-offset: 10px;
  border-radius: 6px;
}

.cth-16__nav {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: clamp(10px,2.4cqi,20px);
}

.cth-16__bar {
  display: grid;
  place-items: center;
  min-height: 48px;
  padding: 0 24px;
  font-size: 15px;
  font-weight: 800;
  letter-spacing: .04em;
  text-transform: uppercase;
  text-decoration: none;
  border: 1.5px solid var(--line);
  border-radius: 10px;
  color: transparent;
  -webkit-text-stroke: 1px color-mix(in oklch,var(--ink) 70%,transparent);
  background-image: linear-gradient(90deg,var(--heat1),var(--heat2));
  background-repeat: no-repeat;
  background-position: 0 0;
  background-size: 0% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  transition: background-size .45s cubic-bezier(.3,.85,.2,1),border-color .3s;
}

.cth-16__bar:hover,
.cth-16__bar:focus-visible {
  background-size: 100% 100%;
  border-color: color-mix(in oklch,var(--heat1) 50%,var(--line));
}

.cth-16__bar:focus-visible {
  outline: 2px solid var(--heat2);
  outline-offset: 3px;
}

.cth-16__hint {
  font-size: 12.5px;
  line-height: 1.7;
  color: var(--mut);
  max-width: 56ch;
}

.cth-16__hint code {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: .9em;
  background: oklch(0.26 0.01 60);
  padding: 1px 6px;
  border-radius: 4px;
  color: var(--heat2);
}

@media (prefers-reduced-motion: reduce) {
  .cth-16 * {
    transition-duration: .01ms !important;
  }
}
```

How this works

Three channels, one element, zero extra markup:

.hollow{
  color:transparent;                       /* kill the fill  */
  -webkit-text-stroke:1.5px var(--stroke); /* draw the wire  */
  background-image:linear-gradient(var(--fill),var(--fill));
  background-repeat:no-repeat;
  background-position:0 100%;              /* pour from the  */
  background-size:100% 0%;                 /* baseline up    */
  -webkit-background-clip:text; background-clip:text;
  transition:background-size .6s cubic-bezier(.3,.85,.2,1); }
.hollow:hover,
.hollow:focus-visible{ background-size:100% 100%; }

The mental model: color:transparent empties the glyph interiors, the stroke re-draws their contours, and the clipped background paints the interiors back wherever it currently covers — so animating background-size IS animating the fill level. Position it at 0 100% and size grows upward (pour); flip to 0 0 for a drain-down; 100% 4px-style horizontal growth (the nav here) reads as progress. Any gradient works as the liquid — the wordmark pours a two-stop heat gradient.

One optical subtlety worth teaching: text-stroke centers on the glyph outline, half inside, half outside. While hollow, you see the full stroke; once filled, the fill covers the inner half — so the letterform gets visually ~1px lighter as it completes. This demo leans in and transitions the stroke color toward the fill on hover, dissolving the wire into the metal. If you need the wire to survive filling, keep stroke and fill high-contrast instead.

Make it yours

  • Stroke weight sets the register: 1px is architectural drafting; 3px is sticker culture. Scale it with font size using clamp, not a fixed px, if the type is fluid.
  • Pour direction grammar: bottom-up = filling, top-down = draining, left-right = progress, center-out (position 50% 50%) = ignition. Pick the metaphor, not the novelty.
  • Slow-pour on arrival, fast on exit: split durations with transition on the base (.35s) vs on :hover (.7s) — reads as heavy in, light out.
  • The stroke color is animatable too: keep fill static and pulse only -webkit-text-stroke color for a cheap 'live wire' idle state.

Gotchas — read before shipping

  • -webkit-text-stroke is prefix-only but universal — every engine (including Firefox) ships it under the -webkit- name; there is no unprefixed form to 'also' write.
  • Thick strokes (3px+) on thin-stroke fonts eat the counters (the holes in a, e, o) — stroke paints centered, and half of 3px inward closes small apertures at small sizes. Display sizes only.
  • background-clip:text + background-size animation re-rasters the gradient per frame; on a hero word that's fine, on 40 list items it isn't. Scope the animated version to a few elements.
  • The clipped background only paints where GLYPHS are — the pour is invisible over spaces and between letters, which is correct, but means wide letter-spacing weakens the 'liquid level' illusion.

Browser support

ChromeSafariFirefoxEdge
111+16.4+113+111+

text-stroke + clipped background-size animation works in all 2020+ engines with prefixes; the floor is oklch()/color-mix() tokens (hex to go older).

Techniques used in this demo

Search CodeFronts

Loading…