The Anatomy of a 3D Button
Buttons drive the web. From checkout flows to media players, nearly every significant interaction relies on them. Yet most buttons are flat, static pixels. With a few clever CSS techniques, though, we can create a button that feels like a physical object—something you actually press.
The core illusion is simple: we layer a bright foreground surface on top of a darker "edge" layer. When pressed, the foreground slides downward, simulating the physical travel of a real button. By animating only transform and filter properties, we can keep the interaction buttery-smooth without the expense of animating box-shadow or border.
Setting Up the Foundation
Our MVP uses the button element itself as the dark edge. We strip its default border and padding, then position a .front layer inside it. The foreground layer carries the color and typography, and we slide it with transform: translate. While the mouse is held, the :active pseudo-class shifts the layer down to near the bottom, leaving a 2px gap to preserve the depth illusion.
Focus Visibility
Standard focus outlines can be ugly, and they behave inconsistently across browsers (Firefox ignores outline-offset for default outlines). But removing the outline entirely would harm keyboard navigation. The solution is the :focus-visible pseudo-class:
[Focus code block placeholder text: <style> /* The CSS we saw earlier is still * being applied. It's tucked into * the "CSS" tab above. */ .pushable:focus:not(:focus-visible) { outline: none; }</style><button class="pushable"> <span class="front"> Push me </span></button>]
The selector .some-element:focus:not(:focus-visible) hides the outline only when focus comes from a pointer device, while keeping a clear visual indicator for keyboard users.
Hover Movement and Transitions
A good 3D button should react before you press it. We can shift the .front layer upward slightly on hover, making the button "rise" toward the cursor. Adding transition to the front layer creates a slick animated response to state changes. A will-change: transform declaration hints to the browser that it should optimize for smooth hardware-accelerated movement.
Adding Personality with Timing
A uniform transition speed for all states feels robotic. Every action—press, release, hover enter, hover exit—should have its own tempo. With CSS only, we specify a default transition on .front, then override it inside :active and :hover rules.
- Press: A lightning-fast 34ms, approximately two frames at 60fps, so the button snaps down.
- Release (via hover): A "springy" custom
cubic-beziercurve that overshoots slightly, giving the button bounce. - Mouse leaves: A long, leisurely 600ms with a more conservative
ease-out-style curve, so the button settles back slowly.
Note that the :hover transition rule also applies when the button snaps back after a release, while the cursor is still hovering. Disambiguating those two events would require JavaScript, which we're avoiding here.
The Shadow and The Stack
To truly sell the depth, we add a shadow—but not with box-shadow. Instead, we repeat our earlier trick: the shadow is its own layer that moves in the opposite direction of the front face.
This requires restructuring. The button becomes a relatively positioned wrapper, ensuring a width and height is declared by an in-flow child. Inside it, we stack three absolutely positioned layers: one for the gradient background, one for the shadow, and one for the front face.
[Shadow HTML code block placeholder text:]<button> <span class="shadow"></span> <span class="edge"></span> <span class="front">Push Me</span> </button>
[Shadow CSS code block placeholder text:<button class="pushable"> <span class="shadow"></span> <span class="edge"></span> <span class="front"> Push me </span></button>]
When the front moves up by 6px, the shadow moves down by only 4px. This mismatch in travel distance is a deliberate design choice for realism. We can soften the shadow further with a blur filter on the layer instead of relying on expensive shadow rendering.
Final Polish
Color and Light
The illusion becomes richer when we make the bottom edge look less flat. A subtle linear gradient on the "edge" element mimics the way rounded corners catch less light. Then, for a hovering highlight, we can apply the brightness() filter (e.g., filter: brightness(1.1)) to the entire button. Both transform and filter are hardware-accelerated and animate at high performance.
Mobile Quirks
On touch devices, mobile browsers often overlay a "tap rectangle" on taps. Since our button gives clear physical feedback, we can safely suppress it with:
[Tap highlight code block placeholder text:].pushable { -webkit-tap-highlight-color: transparent; }
iOS also tries to select text inside buttons when they're held. To prevent this, we make the button unselectable:
[User-select code block placeholder text:].pushable { user-select: none; }
These two properties disable default mobile behaviors, so use them sparingly and only when the custom interaction clearly improves usability.
A Note on Use
This button is theatrical. It feels satisfying to press, making it ideal for significant, celebratory actions. But that same flamboyance would be out of place in a GDPR cookie-consent banner or other mundane controls. Reserve it for the actions that deserve a bit of magic. By layering a few simple CSS properties—movement, timing, and lighting—you can create interactions that feel physical and delightful, not just "dull everyday pixels."



