Skip to content
← writing

Buttons with real depth: stacked gradients + layered shadows

css
tailwind
buttons
frontend

A button is the most-clicked thing you ship, so it's worth more than bg-blue-500. The good ones look like a physical key — a fill that catches light, a hairline edge, a shadow that says press me. Here are two:

Hover to lift them, press to sink them. Three tricks do the work.

1. Two gradients in one background

The fill and the edge are different gradients. CSS lets you paint both in one background by clipping each layer to a different box:

background:
  linear-gradient(180deg, #201e25, #323137) padding-box,  /* fill   */
  linear-gradient(180deg, #4b4951, #313036) border-box;   /* stroke */
border: 1px solid transparent;

The first layer fills the padding box; the second fills all the way to the border edge. With a transparent 1px border, that second gradient shows through only as the border — a stroke that's brighter at the top, like light hitting a bevel. This is the same Fill + Stroke split a design tool gives you, done in one declaration.

2. A three-part shadow

One shadow looks flat. Stack three and the button gains a body:

box-shadow:
  0 2px 4px rgba(0, 0, 0, 0.1),           /* ambient — lifts off the page */
  0 0 0 1px #0d0d0d,                       /* ring — crisp dark edge       */
  inset 0 1px 0 rgba(255, 255, 255, 0.06); /* sheen — top inner highlight  */

Ambient shadow for elevation, a zero-blur 0 0 0 1px ring to sharpen the silhouette, and an inset top highlight for gloss. The light variant just swaps the ring to a translucent black and pushes the inner highlight brighter.

3. States that move

Depth is a promise the button has to keep when you touch it.

.btn-gloss            { --lift: 0px; transform: translateY(var(--lift));
                        transition: transform .16s cubic-bezier(.2,.7,.2,1),
                                    box-shadow .16s ease, filter .16s ease; }
.btn-gloss:hover      { --lift: -1px; filter: brightness(1.08); }  /* rise   */
.btn-gloss:active     { transform: scale(.985); filter: brightness(.96); } /* sink */
.btn-gloss:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }

Hover raises it a pixel and brightens; active scales it down a hair and dims — the whole thing reads as a key being pressed. A custom property (--lift) keeps the transform composable, and a cubic-bezier(.2,.7,.2,1) ease makes the motion snap rather than drift. Wrap the motion in prefers-reduced-motion and you're done.

Tailwind, if you prefer

The shadows and states map cleanly to utilities; only the dual-clip gradient needs an inline style:

<button
  style={{
    background:
      "linear-gradient(180deg,#201E25,#323137) padding-box," +
      "linear-gradient(180deg,#4B4951,#313036) border-box",
  }}
  className="rounded-[18px] border border-transparent px-9 py-3.5 text-xl
    font-semibold text-zinc-100 transition-[transform,box-shadow,filter]
    duration-150 ease-out hover:-translate-y-px hover:brightness-110
    active:translate-y-0 active:scale-[0.985] active:brightness-95
    shadow-[0_2px_4px_rgba(0,0,0,0.1),0_0_0_1px_#0D0D0D,inset_0_1px_0_rgba(255,255,255,0.06)]"
>
  Accept
</button>

Takeaways

  • padding-box + border-box clips give you a gradient fill and a gradient stroke from a single background.
  • Layer three shadows — ambient, ring, inset sheen — for depth a single shadow can't fake.
  • Make :hover rise and :active sink; a button that moves feels real.

Grab the prop-driven component on the Glossy Button page.