51 CSS Buttons19 / 51

Pure CSSMIT licensed

CSS Editorial Ink Spread Button

Letterpress restraint: a serif label on cream stock, a hairline rule that grows into a full underline, and an ink blot that floods from the baseline on hover with a soft irregular edge — a masked radial gradient scaling up, so it feels absorbed rather than filled.

Published Updated

Live Demo
Try it

The code

<div class="cb-19">
  <div class="cb-19__stage">
    <button class="cb-19__btn" type="button">
      <span class="cb-19__ink" aria-hidden="true"></span>
      <span class="cb-19__label">Read the essay</span>
    </button>
  </div>
</div>
@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap');

.cb-19 {
  --paper: #f4efe4;
  --ink: #1a1614;
  --accent: #8c2f1f;
  --radius: .25rem;
  --serif: "Instrument Serif",ui-serif,Georgia,"Times New Roman",serif;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: radial-gradient(60rem 40rem at 50% 0%, color-mix(in oklab,white 55%,transparent), transparent 60%), var(--paper);
  font-family: var(--serif);
}

@supports (color: oklch(50% 0 0)) {
  .cb-19 {
    --paper: oklch(95% .018 85);
    --ink: oklch(20% .012 40);
    --accent: oklch(43% .13 32);
  }
}

@media (prefers-color-scheme: dark) {
  .cb-19:not([data-theme="light"]) {
    --paper: #14110f;
    --ink: #f2ebdd;
    --accent: #e08b6d;
  }
}

.cb-19[data-theme="dark"] {
  --paper: #14110f;
  --ink: #f2ebdd;
  --accent: #e08b6d;
}

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

.cb-19__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.5rem,6vw,4rem);
}

.cb-19__btn {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  cursor: pointer;
  min-height: 3.5rem;
  padding: .35rem 1.9rem .5rem;
  border: 1px solid color-mix(in oklab,var(--ink) 30%,transparent);
  border-radius: var(--radius);
  background: transparent;
  color: var(--ink);
  font: inherit;
  font-size: 1.375rem;
  letter-spacing: .005em;
  font-variant-ligatures: common-ligatures;
  transition: border-color .4s ease, color .34s ease .1s;
}

.cb-19__label {
  position: relative;
  z-index: 2;
}

.cb-19__label::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: -.28rem;
  height: 1px;
  background: currentColor;
  scale: 0 1;
  transform-origin: left;
  transition: scale .5s cubic-bezier(.16,1,.3,1);
}

.cb-19__ink {
  position: absolute;
  inset: -20% -10%;
  z-index: 1;
  background: var(--ink);
  -webkit-mask: radial-gradient(58% 120% at 50% 112%, #000 38%, rgba(0,0,0,.5) 64%, transparent 80%);
  mask: radial-gradient(58% 120% at 50% 112%, #000 38%, rgba(0,0,0,.5) 64%, transparent 80%);
  scale: .25;
  opacity: 0;
  transform-origin: 50% 100%;
  transition: scale .62s cubic-bezier(.16,1,.3,1), opacity .34s ease;
}

.cb-19__btn:is(:hover,:focus-visible) {
  color: var(--paper);
  border-color: var(--ink);
}

.cb-19__btn:is(:hover,:focus-visible) .cb-19__ink {
  scale: 1.45;
  opacity: 1;
}

.cb-19__btn:is(:hover,:focus-visible) .cb-19__label::after {
  scale: 1 1;
}

.cb-19__btn:focus-visible {
  outline: 1px solid var(--accent);
  outline-offset: 5px;
}

.cb-19__btn:active {
  scale: .99;
}

.cb-19__btn:active .cb-19__ink {
  background: var(--accent);
}

.cb-19__btn:disabled {
  cursor: not-allowed;
  color: color-mix(in oklab,var(--ink) 45%,var(--paper));
  border-color: color-mix(in oklab,var(--ink) 15%,transparent);
}

.cb-19__btn:disabled .cb-19__ink {
  display: none;
}

@media (prefers-reduced-motion: reduce) {
  .cb-19__btn,
    .cb-19__ink,
    .cb-19__label::after {
    transition: color .12s linear, background-color .12s linear;
  }

  .cb-19__ink {
    scale: 1.45;
    opacity: 0;
  }

  .cb-19__btn:is(:hover,:focus-visible) .cb-19__ink {
    opacity: 1;
  }
}

@media (forced-colors: active) {
  .cb-19__btn {
    color: ButtonText;
    border-color: ButtonText;
    background: ButtonFace;
  }

  .cb-19__ink {
    display: none;
  }

  .cb-19__btn:hover {
    background: Highlight;
    color: HighlightText;
  }
}
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 Button 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: CSS Editorial Ink Spread Button
Source: https://codefronts.com/components/css-buttons/css-editorial-ink-spread-button/

Letterpress restraint: a serif label on cream stock, a hairline rule that grows into a full underline, and an ink blot that floods from the baseline on hover with a soft irregular edge — a masked radial gradient scaling up, so it feels absorbed rather than filled.
## HTML
```html
<div class="cb-19">
  <div class="cb-19__stage">
    <button class="cb-19__btn" type="button">
      <span class="cb-19__ink" aria-hidden="true"></span>
      <span class="cb-19__label">Read the essay</span>
    </button>
  </div>
</div>
```
## CSS
```css
@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap');

.cb-19 {
  --paper: #f4efe4;
  --ink: #1a1614;
  --accent: #8c2f1f;
  --radius: .25rem;
  --serif: "Instrument Serif",ui-serif,Georgia,"Times New Roman",serif;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: radial-gradient(60rem 40rem at 50% 0%, color-mix(in oklab,white 55%,transparent), transparent 60%), var(--paper);
  font-family: var(--serif);
}

@supports (color: oklch(50% 0 0)) {
  .cb-19 {
    --paper: oklch(95% .018 85);
    --ink: oklch(20% .012 40);
    --accent: oklch(43% .13 32);
  }
}

@media (prefers-color-scheme: dark) {
  .cb-19:not([data-theme="light"]) {
    --paper: #14110f;
    --ink: #f2ebdd;
    --accent: #e08b6d;
  }
}

.cb-19[data-theme="dark"] {
  --paper: #14110f;
  --ink: #f2ebdd;
  --accent: #e08b6d;
}

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

.cb-19__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.5rem,6vw,4rem);
}

.cb-19__btn {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  cursor: pointer;
  min-height: 3.5rem;
  padding: .35rem 1.9rem .5rem;
  border: 1px solid color-mix(in oklab,var(--ink) 30%,transparent);
  border-radius: var(--radius);
  background: transparent;
  color: var(--ink);
  font: inherit;
  font-size: 1.375rem;
  letter-spacing: .005em;
  font-variant-ligatures: common-ligatures;
  transition: border-color .4s ease, color .34s ease .1s;
}

.cb-19__label {
  position: relative;
  z-index: 2;
}

.cb-19__label::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: -.28rem;
  height: 1px;
  background: currentColor;
  scale: 0 1;
  transform-origin: left;
  transition: scale .5s cubic-bezier(.16,1,.3,1);
}

.cb-19__ink {
  position: absolute;
  inset: -20% -10%;
  z-index: 1;
  background: var(--ink);
  -webkit-mask: radial-gradient(58% 120% at 50% 112%, #000 38%, rgba(0,0,0,.5) 64%, transparent 80%);
  mask: radial-gradient(58% 120% at 50% 112%, #000 38%, rgba(0,0,0,.5) 64%, transparent 80%);
  scale: .25;
  opacity: 0;
  transform-origin: 50% 100%;
  transition: scale .62s cubic-bezier(.16,1,.3,1), opacity .34s ease;
}

.cb-19__btn:is(:hover,:focus-visible) {
  color: var(--paper);
  border-color: var(--ink);
}

.cb-19__btn:is(:hover,:focus-visible) .cb-19__ink {
  scale: 1.45;
  opacity: 1;
}

.cb-19__btn:is(:hover,:focus-visible) .cb-19__label::after {
  scale: 1 1;
}

.cb-19__btn:focus-visible {
  outline: 1px solid var(--accent);
  outline-offset: 5px;
}

.cb-19__btn:active {
  scale: .99;
}

.cb-19__btn:active .cb-19__ink {
  background: var(--accent);
}

.cb-19__btn:disabled {
  cursor: not-allowed;
  color: color-mix(in oklab,var(--ink) 45%,var(--paper));
  border-color: color-mix(in oklab,var(--ink) 15%,transparent);
}

.cb-19__btn:disabled .cb-19__ink {
  display: none;
}

@media (prefers-reduced-motion: reduce) {
  .cb-19__btn,
    .cb-19__ink,
    .cb-19__label::after {
    transition: color .12s linear, background-color .12s linear;
  }

  .cb-19__ink {
    scale: 1.45;
    opacity: 0;
  }

  .cb-19__btn:is(:hover,:focus-visible) .cb-19__ink {
    opacity: 1;
  }
}

@media (forced-colors: active) {
  .cb-19__btn {
    color: ButtonText;
    border-color: ButtonText;
    background: ButtonFace;
  }

  .cb-19__ink {
    display: none;
  }

  .cb-19__btn:hover {
    background: Highlight;
    color: HighlightText;
  }
}
```

How this works

The ink is one layer whose mask is a radial gradient with a deliberately soft stop, scaled from the bottom edge. Because the mask itself has feathering, the growing edge is organic rather than a hard circle:

.cb-19__ink{
  background:var(--ink);
  -webkit-mask:radial-gradient(60% 120% at 50% 108%,#000 38%,rgba(0,0,0,.55) 62%,transparent 78%);
  mask:radial-gradient(60% 120% at 50% 108%,#000 38%,rgba(0,0,0,.55) 62%,transparent 78%);
  scale:.2; opacity:0; transform-origin:50% 100%;
  transition:scale .62s cubic-bezier(.16,1,.3,1), opacity .3s ease;
}
.cb-19__btn:is(:hover,:focus-visible) .cb-19__ink{ scale:1.35; opacity:1; }

The label sits above the ink and switches to paper colour with a slight delay so the flood overtakes it — the same crossover timing trick as the ghost button, tuned longer for a print feel.

The rule beneath is a 1px pseudo-element growing from scale:0 1 with transform-origin:left, and the whole control uses font-variant-ligatures plus optical letter-spacing so the serif does not look like body copy at button size.

Make it yours

  • Change the flood origin by moving the mask's centre — 50% 108% rises from the baseline, 10% 50% spreads from the left like a nib stroke.
  • For a two-colour press effect, layer a second ink element with a different mask and a 90ms delay.
  • Use --paper and --ink to invert into a dark-stock variant; the mask logic is colour-agnostic.
  • Swap the serif for your display face — keep the size at or above 1rem so the letterforms stay legible.
  • Drop the underline rule if the button sits inside prose, where it would read as a link.

Gotchas — read before shipping

  • Long transitions feel broken on a control that performs an action. 620ms is at the top of the acceptable range; anything slower needs a faster leave transition.
  • Masked layers are composited: keep the mask on the ink element, not on the button, or the label gets masked too.
  • Feathered masks lose contrast in the transition zone — the label colour must be legible against both the paper and the ink, not just the ink.
  • Safari needs -webkit-mask; declare it first so the unprefixed property wins in modern engines.
  • Serif fonts at small sizes plus low-contrast paper is an accessibility trap; keep body text ≥ 1rem and contrast ≥ 4.5:1.

Browser support

ChromeSafariFirefoxEdge
120+15.4+53+120+

mask shorthand: Chrome 120 / Safari 15.4 (prefixed earlier) / Firefox 53. Without mask support the ink still floods as a plain scaling rectangle, which is a perfectly acceptable fallback.

Techniques used in this demo

Search CodeFronts

Loading…