The candy button: a glossy 3D CTA from four shadows
css
tailwind
buttons
frontend
Ask your agent to implement this
Read the full writeup at https://seangeng.com/writing/the-candy-button.md and implement it in my project.
It covers: The candy button: a glossy 3D CTA from four shadows. How to puff a flat blue rectangle into a tactile, pressable call-to-action using nothing but layered box-shadows: drop, ring, inset stroke, and a top sheen, plus hover and press states. 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.
Some buttons beg to be clicked. They look like a glossy candy or a physical key:
puffed up, catching light, casting a soft shadow. The surprise is that there's
no 3D and no images involved. It's four box-shadows stacked on a blue rectangle.
Hover to float it, press to push it in. Here's every layer.
Four shadows, one rectangle
Read these from the outside in:
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.2), /* 1. drop: elevation off the page */ 0 0 0 1px #296ff0, /* 3. ring: seats the blue edge */ inset 0 0 0 1px rgba(255, 255, 255, 0.4), /* stroke: bright 1px rim */ inset 0 1px 6px 2px rgba(255, 255, 255, 0.24); /* 2. sheen: inner top gloss */
The drop shadow (0 3px 6px, black 20%) lifts the button off the surface so it
floats. The blue ring (0 0 0 1px, the same fill blue) wraps the edge so it
reads as a solid object instead of a flat fill someone pasted down. The inset
white stroke (white 40%) is the bright rim along the top and sides, where the
"plastic" curves over. And the inset sheen (white 24%) is the soft glow just
under the top edge. That last one is the single layer that turns a blue box into
glossy candy.
It maps one-to-one to the Fill / Stroke / Drop / Inner layers you'd set in a
design tool, except the browser does all of it in one property.
States that feel physical
A 3D look has to react to touch or the illusion breaks.
.btn-candy { --lift: 0px; transform: translateY(var(--lift)); transition: transform .16s cubic-bezier(.2,.7,.2,1), box-shadow .16s ease, filter .16s ease;}/* rise: the drop shadow grows and tints blue, so it floats */.btn-candy:hover { --lift: -2px; filter: brightness(1.05); box-shadow: 0 8px 18px rgba(41,111,240,.35), 0 0 0 1px #296ff0, inset 0 0 0 1px rgba(255,255,255,.45), inset 0 1px 6px 2px rgba(255,255,255,.3);}/* sink: swap to an inset dark shadow, so it presses in */.btn-candy:active { transform: translateY(1px) scale(.99); filter: brightness(.97); box-shadow: 0 1px 3px rgba(0,0,0,.25), 0 0 0 1px #2563d8, inset 0 2px 6px rgba(0,0,0,.2);}
On hover the button rises and grows a blue-tinted shadow, so it lifts toward
you. Active flips the logic: the elevation shadow collapses, an inset dark
shadow appears, and it scales down a touch. Light now falls into
it, which is the universal signal for pressed. The cubic-bezier(.2,.7,.2,1)
keeps the motion crisp, and it all sits behind prefers-reduced-motion.
Tailwind, if you prefer
Everything maps to utilities. The gradient and the long shadow lists go in
arbitrary values:
The takeaway I keep coming back to: a "3D" button is really just layered
shadows, and the inset white sheen is the one doing the heavy lifting. Invert
the shadow on :active (outer to inset, light to dark) and the press feels
real.
Grab the prop-driven component on the
Candy Button page.