Most demos need a newer browser

They use :has() and container queries. Update your browser, or on an iPhone update iOS to 16.4 or later.

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

flat + top light + bottom shade + drop shadows

The same button, built up layer by layer

See the code

Build 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 inset to outer, the browser cancels the whole box-shadow transition, and every slot jumps to its end values.
  • Theming the gradient or shadow colors with CSS custom properties that change between site themes.
  • Forgetting transition on box-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 Chrome Firefox Safari Edge
box-shadow43.5512
gradients103615.412
transition455.112
filter183.6613

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?
A shadow is changing type at the same slot, for example from 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?
The look depends on specific ratios between the highlight, shadow, and surface colors.
Can this style apply to a link element?
The CSS rules work on any block or inline-block element. A link with 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.
What changes about the shadows when the button is pressed?
At rest the top highlight and bottom shade simulate a raised surface catching light from above. On press both insets flip roles and the outset shadows shrink, as if the face sank below the rim.
How do I scale this to very small or very large buttons?
Scale the shadow offsets roughly in proportion to the button height.