Buttons with real depth: stacked gradients + layered shadows
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