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 deserves 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 small tricks do the work.

1. Two gradients in one background

The fill and the edge are different gradients. CSS lets you paint both in a single 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, and since the border is a transparent 1px, that gradient only ever shows up as the border. You get a stroke that's brighter at the top, like light hitting a bevel. It's the same Fill + Stroke split your design tool gives you, in one declaration.

2. A three-part shadow

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

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

The ambient shadow handles elevation. The zero-blur 0 0 0 1px ring sharpens the silhouette. The inset highlight is the gloss along the top. The light variant is the same recipe with the ring swapped for a translucent black and the inner highlight pushed brighter.

3. States that move

Depth is a promise, and the button has to keep it 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 it. Active scales it down a hair and dims it. Together they read as a key being pressed. The --lift custom property keeps the transform composable, and the cubic-bezier(.2,.7,.2,1) ease makes the motion snap instead of drift. Wrap it in prefers-reduced-motion and you're done.

Tailwind, if you prefer

The shadows and states map straight 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>

The whole thing is two clipped gradients, three stacked shadows, and a press that moves. None of it is expensive, and it's the difference between a rectangle and something that feels clickable.

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

Ask your agent to implement this

Read the full writeup at https://seangeng.com/writing/buttons-with-real-depth.md and implement it in my project.

It covers: Buttons with real depth: stacked gradients + layered shadows. How a flat rectangle becomes a tactile, pressable button: two clipped gradients for fill and stroke, a three-part box-shadow, and hover/press states that actually move. Tailwind and plain-CSS versions.

Requirements:
- Follow the technique/approach exactly as described in the writeup.
- Adapt names, colors, and styling to my project's existing conventions.
- If it's a component, make it reusable with sensible props and TypeScript types.
- Keep it accessible: semantic HTML, keyboard support, and respect prefers-reduced-motion.
- When done, tell me which files you created or changed and how to use it.

Paste into Claude Code, Codex, Cursor, or any agent. view raw .md download source .zip