CSS Tactile Button
Buttons with depth from four box-shadow layers: a top highlight, a bottom shade, a contact shadow that vanishes on press, and a cast shadow that tightens.
- Level
- Beginner
- Published
- Updated
- Reading time
- 5 min
Try it
2 live demos
Buttons you want to press
Other ways to build it
The same button, built up layer by layer
See the codeBuild it
4 snippets to copy
CSS Tactile Button
HTML
<button class="tb-button" type="button">Save draft</button>
<button class="tb-button tb-accent" type="button">Publish</button>
CSS
.tb-button {
padding: .75rem 1.4rem;
border: 0;
border-radius: 12px;
font-weight: 600;
color: #f4f4f5;
cursor: pointer;
background: linear-gradient(#3b3b42, #26262b);
/* top edge catching light, bottom edge in shade,
contact shadow, cast shadow */
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .2),
inset 0 -2px 0 rgba(0, 0, 0, .35),
0 1px 1px rgba(0, 0, 0, .5),
0 6px 14px rgba(0, 0, 0, .4);
transition: transform .12s, box-shadow .12s, filter .12s;
}
.tb-accent {
color: #16210a;
background: linear-gradient(#caff7a, #9fe63e);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .6),
inset 0 -2px 0 rgba(60, 100, 0, .35),
0 1px 1px rgba(0, 0, 0, .5),
0 6px 14px rgba(120, 190, 30, .3);
}
.tb-button:hover {
filter: brightness(1.08);
}
/* Keep each slot the same type as the rest state: inset stays
inset and outer stays outer. One slot that changes type
cancels the whole transition. */
.tb-button:active {
transform: translateY(2px);
box-shadow:
inset 0 2px 5px rgba(0, 0, 0, .5),
inset 0 -1px 0 rgba(255, 255, 255, .08),
0 0 0 rgba(0, 0, 0, 0),
0 1px 2px rgba(0, 0, 0, .3);
}
The same button, built up layer by layer
Each span shows one more step of the shadow stack: from a plain flat gradient to the top-edge highlight, then the bottom shade, then both outset shadows together. These are not interactive controls; they have no press state.
HTML
<span class="tb-layer">flat</span>
<span class="tb-layer tb-layer-2">+ top light</span>
<span class="tb-layer tb-layer-3">+ bottom shade</span>
<span class="tb-layer tb-layer-4">+ drop shadows</span>
CSS
.tb-layer {
display: inline-block;
padding: .6rem 1rem;
border-radius: 12px;
color: #f4f4f5;
background: linear-gradient(#3b3b42, #26262b);
}
.tb-layer-2 {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .2);
}
.tb-layer-3 {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .2),
inset 0 -2px 0 rgba(0, 0, 0, .35);
}
.tb-layer-4 {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .2),
inset 0 -2px 0 rgba(0, 0, 0, .35),
0 1px 1px rgba(0, 0, 0, .5),
0 6px 14px rgba(0, 0, 0, .4);
}
Understand it
How it works and 5 properties
Depth in a flat button comes from simulating what light does to a physical surface. A top-edge highlight, a bottom-edge shade, a contact shadow directly below the button, and a softer cast shadow further out: four box-shadow layers in one declaration.
The pressed state uses the same four layers reconfigured, and the button shifts two pixels down with translateY.
How it works
Two of the four shadows are inset. The first, at 0 1px 0, places a faint white line at the top edge where a light source from above would catch it. The second, at 0 -2px 0, sits at the bottom edge. Two are outset: a 1-pixel contact shadow directly under the base, and a taller cast shadow with a 14-pixel blur. On press, transform: translateY(2px) moves the button down, and a new list of four replaces the resting one: the top becomes a deep inset shadow, the bottom becomes a faint light line, and both outset shadows collapse to nearly nothing.
The top inset highlight catches light from above. The bottom inset shade sits in the shadow behind the raised face. The contact shadow, a tight 1-pixel blur, marks where the button nearly meets the surface. The cast shadow, at 14px blur, is what the raised button projects away from itself.
The browser pads shadow lists of different lengths by treating missing entries as transparent zero shadows, so list lengths alone do not prevent tweening. What stops a smooth transition is a type mismatch at the same slot: an inset shadow in the resting state and an outer shadow at the same position in the active state cannot interpolate. The CSS keeps every slot the same type across both states to avoid that snap.
The colors are hard-coded rather than drawn from the site theme, and that is intentional. The depth model requires specific values for the light edge, the dark edge, and the cast shadow. Using CSS custom properties that shift with the site's dark and light themes could break the relationship in one of the themes, where the surface color might become lighter than its own highlight. Pinned values keep both buttons consistent across both themes.
The hover state adds filter: brightness(1.08), which lifts the whole surface slightly. Changing the gradient stop colors on hover would require rechecking the shadow values against the new surface colors. A uniform brightness filter avoids that and keeps all four shadow relationships intact.
The accent button shares all the interaction rules but uses its own shadow values. A lighter surface needs a stronger edge to read as raised, so the top highlight is more opaque. Its cast shadow tints toward green. On press the full list is replaced, including that colored cast shadow.
Accessibility notes
The buttons are real <button> elements: keyboard-focusable and activatable with Enter and Space. The depth effect sits on top of fully functional native controls. The full CSS file adds a focus-visible outline. The snippet on this page does not include it, so add that rule separately.
The 2px downward movement on press is small and brief. It is not a repeated or sustained animation and does not require a reduced-motion exception for most users.
The accent button's cast shadow tints green. That color is decorative and not the only way the button is identified, so it does not need to meet contrast requirements for non-text graphics.
Mistakes worth avoiding
- Switching a shadow's type at the same slot between states. When one slot changes from
insetto outer, the browser cancels the wholebox-shadowtransition, and every slot jumps to its end values. - Theming the gradient or shadow colors with CSS custom properties that change between site themes.
- Forgetting
transitiononbox-shadow. The shadows swap instantly and the press reads as a jump. - Omitting a hover state. A button that looks identical at rest and on hover gives no feedback before the click.
- Using the same shadow offsets on very small buttons. A 6-pixel cast shadow proportioned for a full-size button can overpower a compact target.
Ship it
4 features in 4 browsers, 5 questions
Browser support
| Feature | ||||
|---|---|---|---|---|
box-shadow | 4 | 3.5 | 5 | 12 |
gradients | 10 | 36 | 15.4 | 12 |
transition | 4 | 5 | 5.1 | 12 |
filter | 18 | 3.6 | 6 | 13 |
box-shadow reaches Chrome 4 and Safari 5. filter came later, at Chrome 18 and Safari 6.
What you can build with it
- Primary and secondary action buttons in a form. The demo pair shows this directly: a neutral default and an accented confirmation.
- Keyboard shortcut key graphics. Decorative
<kbd>elements that look like physical keys on a diagram or in a help screen. - Toolbar buttons. Square or rectangular icon buttons where depth makes each one read as a separate pressable surface.
- Payment or checkout buttons. High-stakes actions where a physical affordance reinforces that something will happen.
- Game or remote control UI. A software interface designed to reference a physical device, where every button should look pushable.
Frequently asked questions
Why do my box-shadow transitions snap instead of animating?
inset to outer. Keep each slot's type consistent across both states. The count does not have to match: the browser pads shorter lists with transparent zero shadows.Why are the button colors hard-coded instead of using CSS custom properties?
Can this style apply to a link element?
display: inline-block and the same classes picks up the shadow stack. Its text underline remains unless explicitly removed. The :active pseudo-class fires on links as well.