51 CSS Buttons03 / 51

Pure CSSMIT licensed

CSS Ghost / Outline Button

A ghost button that keeps its 1px silhouette at rest and fills from the bottom edge on hover, with the label colour inverting in the same beat. The fill is one background-size transition on a gradient — no pseudo-element, no overflow clipping, and the border is drawn in currentColor so a single colour change re-skins everything.

Published

Live Demo
Try it

The code

<div class="cb-03">
  <div class="cb-03__stage">
    <button class="cb-03__btn" type="button">
      <span>Read the case study</span>
      <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17 17 7"/><path d="M8.5 7H17v8.5"/></svg>
    </button>
  </div>
</div>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap');

.cb-03 {
  --bg: #faf9f6;
  --accent: #111827;
  --on-accent: #f9fafb;
  --border-w: 1px;
  --radius: 999px;
  --sans: "Space Grotesk",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--bg);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
}

@supports (color: oklch(50% 0 0)) {
  .cb-03 {
    --bg: oklch(97.8% .006 95);
    --accent: oklch(22% .02 265);
    --on-accent: oklch(98% .003 265);
  }
}

@media (prefers-color-scheme: dark) {
  .cb-03:not([data-theme="light"]) {
    --bg: #0b0b0c;
    --accent: #f4f4f0;
    --on-accent: #101012;
  }
}

.cb-03[data-theme="dark"] {
  --bg: #0b0b0c;
  --accent: #f4f4f0;
  --on-accent: #101012;
}

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

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

.cb-03__btn {
  cursor: pointer;
  display: grid;
  grid-auto-flow: column;
  align-items: center;
  gap: .65rem;
  min-height: 3.25rem;
  padding: 0 1.75rem;
  border: var(--border-w) solid currentColor;
  border-radius: var(--radius);
  font: inherit;
  font-size: .9375rem;
  font-weight: 500;
  letter-spacing: .01em;
  color: var(--accent);
  background-color: transparent;
  background-image: linear-gradient(var(--accent),var(--accent));
  background-repeat: no-repeat;
  background-position: 50% 100%;
  background-size: 100% 0%;
  transition: background-size .42s cubic-bezier(.16,1,.3,1), color .3s ease .06s, letter-spacing .42s cubic-bezier(.16,1,.3,1);
}

.cb-03__btn svg {
  width: 1.05rem;
  height: 1.05rem;
  transition: rotate .42s cubic-bezier(.16,1,.3,1);
}

.cb-03__btn:is(:hover,:focus-visible) {
  background-size: 100% 100%;
  color: var(--on-accent);
  letter-spacing: .03em;
}

.cb-03__btn:is(:hover,:focus-visible) svg {
  rotate: 45deg;
}

.cb-03__btn:active {
  scale: .985;
}

.cb-03__btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
}

.cb-03__btn:disabled {
  cursor: not-allowed;
  opacity: .4;
  background-size: 100% 0%;
}

@media (prefers-reduced-motion: reduce) {
  .cb-03__btn,
    .cb-03__btn svg {
    transition: color .1s linear, background-size .1s linear;
  }
}

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

  .cb-03__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 Ghost / Outline Button
Source: https://codefronts.com/components/css-buttons/css-ghost-outline-button/

A ghost button that keeps its 1px silhouette at rest and fills from the bottom edge on hover, with the label colour inverting in the same beat. The fill is one background-size transition on a gradient — no pseudo-element, no overflow clipping, and the border is drawn in currentColor so a single colour change re-skins everything.
## HTML
```html
<div class="cb-03">
  <div class="cb-03__stage">
    <button class="cb-03__btn" type="button">
      <span>Read the case study</span>
      <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17 17 7"/><path d="M8.5 7H17v8.5"/></svg>
    </button>
  </div>
</div>
```
## CSS
```css
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap');

.cb-03 {
  --bg: #faf9f6;
  --accent: #111827;
  --on-accent: #f9fafb;
  --border-w: 1px;
  --radius: 999px;
  --sans: "Space Grotesk",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--bg);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
}

@supports (color: oklch(50% 0 0)) {
  .cb-03 {
    --bg: oklch(97.8% .006 95);
    --accent: oklch(22% .02 265);
    --on-accent: oklch(98% .003 265);
  }
}

@media (prefers-color-scheme: dark) {
  .cb-03:not([data-theme="light"]) {
    --bg: #0b0b0c;
    --accent: #f4f4f0;
    --on-accent: #101012;
  }
}

.cb-03[data-theme="dark"] {
  --bg: #0b0b0c;
  --accent: #f4f4f0;
  --on-accent: #101012;
}

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

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

.cb-03__btn {
  cursor: pointer;
  display: grid;
  grid-auto-flow: column;
  align-items: center;
  gap: .65rem;
  min-height: 3.25rem;
  padding: 0 1.75rem;
  border: var(--border-w) solid currentColor;
  border-radius: var(--radius);
  font: inherit;
  font-size: .9375rem;
  font-weight: 500;
  letter-spacing: .01em;
  color: var(--accent);
  background-color: transparent;
  background-image: linear-gradient(var(--accent),var(--accent));
  background-repeat: no-repeat;
  background-position: 50% 100%;
  background-size: 100% 0%;
  transition: background-size .42s cubic-bezier(.16,1,.3,1), color .3s ease .06s, letter-spacing .42s cubic-bezier(.16,1,.3,1);
}

.cb-03__btn svg {
  width: 1.05rem;
  height: 1.05rem;
  transition: rotate .42s cubic-bezier(.16,1,.3,1);
}

.cb-03__btn:is(:hover,:focus-visible) {
  background-size: 100% 100%;
  color: var(--on-accent);
  letter-spacing: .03em;
}

.cb-03__btn:is(:hover,:focus-visible) svg {
  rotate: 45deg;
}

.cb-03__btn:active {
  scale: .985;
}

.cb-03__btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
}

.cb-03__btn:disabled {
  cursor: not-allowed;
  opacity: .4;
  background-size: 100% 0%;
}

@media (prefers-reduced-motion: reduce) {
  .cb-03__btn,
    .cb-03__btn svg {
    transition: color .1s linear, background-size .1s linear;
  }
}

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

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

How this works

The trick is animating background-size on a fixed-position gradient rather than moving a layer, which keeps the fill perfectly clipped to the border radius for free:

.cb-03__btn{
  color:var(--accent);
  border:1px solid currentColor;
  background-image:linear-gradient(var(--accent),var(--accent));
  background-repeat:no-repeat;
  background-position:50% 100%;
  background-size:100% 0%;
  transition:background-size .42s cubic-bezier(.16,1,.3,1), color .3s ease .06s;
}
.cb-03__btn:is(:hover,:focus-visible){
  background-size:100% 100%;
  color:var(--on-accent);
}

The color transition is delayed 60ms so the label flips after the fill has passed its midpoint — without that delay the text momentarily sits at the same lightness as the rising fill and disappears. Because the border is currentColor, it inverts along with the label and needs no second declaration.

Make it yours

  • Change the fill direction by moving background-position: 0% 50% wipes left-to-right, 50% 0% drops from the top.
  • For a diagonal wipe, swap in a linear-gradient(120deg, …) and animate background-size:200% 200% with a shifting position.
  • Set --border-w:2px for a bolder editorial ghost; keep padding in rem so the height doesn't shift.
  • A dashed variant is one line: border-style:dashed — useful for "add item" placeholders.
  • Reduce the easing duration to .24s for dense UI; the long 420ms curve suits marketing pages.

Gotchas — read before shipping

  • Ghost buttons fail contrast easily: the label needs 4.5:1 against the page, and the 1px border needs 3:1 as a non-text element. A light grey outline on white passes neither.
  • Percentage background-size on a zero dimension needs the paired axis to be explicit — write 100% 0%, not 0%, or the transition will not interpolate in Safari.
  • Users read ghost buttons as disabled when they sit next to a filled primary with no label difference. Pair with clear verbs.
  • If you swap to a pseudo-element fill instead, remember overflow:hidden will clip the focus outline; keep the outline outside via outline-offset.
  • :is(:hover,:focus-visible) collapses two selectors, but an unsupported pseudo inside :is() is forgiving — it will not invalidate the whole rule, which is the behaviour you want here.

Browser support

ChromeSafariFirefoxEdge
111+15.4+113+111+

Floor set by oklch() as this demo is written. The core technique itself goes back further — Chrome 88+.

:is() needs Chrome 88 / Safari 14 / Firefox 78; split the selector for older engines. background-size transitions and currentColor borders are supported everywhere relevant.

Techniques used in this demo

Search CodeFronts

Loading…