Buttons with real depth: stacked gradients + layered shadows
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-boxclips give you a gradient fill and a gradient stroke from a singlebackground.- Layer three shadows — ambient, ring, inset sheen — for depth a single shadow can't fake.
- Make
:hoverrise and:activesink; a button that moves feels real.
Grab the prop-driven component on the Glossy Button page.