Claymorphism Button in React: Puffy 3D Buttons with CSS box-shadow
Build puffy 3D claymorphism buttons in React using layered CSS box-shadow. Code examples, variants, hover animations, and Tailwind shortcuts included.
What Makes a Button Look "Clay"
Claymorphism is a UI style that came out of Dribbble around 2021–2022 and basically never left. The look is inflated, almost edible — buttons that look like they're made of modeling clay rather than flat pixels. And the secret ingredient, every single time, is box-shadow.
Flat shadows won't cut it here. The clay effect requires at least three shadow layers stacked on top of each other: one for the outer drop shadow, one for the inner highlight (using inset), and optionally a third for a colored glow that ties the button to its background. Get all three right and the button practically pops off the screen.
Worth noting: this isn't the same as neumorphism. Glassmorphism vs neumorphism has a full breakdown of the differences, but the short version is — neumorphism carves into the surface, claymorphism inflates above it. Two very different vibes.
The border-radius is also doing serious work. You're typically looking at 16px minimum, often going up to 24px or even 32px for smaller buttons. Combine that with the shadow stack and you get something that reads as genuinely three-dimensional without a single line of WebGL.
The Core CSS Box-Shadow Formula
Here's the exact shadow stack I use for a basic clay button — copy this, then tune the colors to match your palette:
``css
.clay-button {
border-radius: 20px;
border: none;
padding: 14px 28px;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
background-color: #6c63ff;
color: #fff;
box-shadow:
0 8px 0 0 #4a42cc, /* bottom hard shadow — gives the 3D "lift" */
0 12px 24px 0 rgba(108, 99, 255, 0.45), /* soft ambient glow */
inset 0 2px 4px 0 rgba(255, 255, 255, 0.35); /* top highlight */
transition: box-shadow 120ms ease, transform 120ms ease;
}
`
The 0 8px 0 0` shadow is the backbone. It's a hard, zero-blur offset that simulates a physical bottom face on the button — 8px of apparent depth. Dial that value up to 12px for extra puff, down to 4px for a more subtle effect.
The ambient glow at 0 12px 24px softens everything. Without it, the hard bottom shadow looks cartoonish in a bad way. This blur bleeds the button's color into the background just enough to make it feel embedded in space rather than slapped on top of it.
The inset shadow on top is optional but makes a noticeable difference — it simulates light hitting the top face of the clay. Keep the opacity low (around 0.35) or it'll look like a cheap gradient shortcut rather than a real highlight.
In practice, I also add a slight transform: translateY(-1px) at rest to offset the bottom shadow visually. It's a 1px trick that makes the resting state look more "lifted" without needing to change the shadow values.
Building a Reusable React Component
Let's put this into an actual React component you can drop into any project. We'll support a few size variants and a color prop so you're not rewriting the shadow math every time you need a new color:
``tsx
import React, { CSSProperties } from 'react';
type ClayButtonProps = {
children: React.ReactNode;
color?: string; // hex background, e.g. "#6c63ff"
shadowColor?: string; // hex for bottom + glow, e.g. "#4a42cc"
size?: 'sm' | 'md' | 'lg';
onClick?: () => void;
disabled?: boolean;
};
const sizeMap = {
sm: { padding: '8px 18px', fontSize: '0.85rem', borderRadius: '14px', shadowDepth: '6px' },
md: { padding: '14px 28px', fontSize: '1rem', borderRadius: '20px', shadowDepth: '8px' },
lg: { padding: '18px 36px', fontSize: '1.15rem', borderRadius: '24px', shadowDepth: '10px' },
};
export function ClayButton({
children,
color = '#6c63ff',
shadowColor = '#4a42cc',
size = 'md',
onClick,
disabled = false,
}: ClayButtonProps) {
const s = sizeMap[size];
const baseStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
border: 'none',
cursor: disabled ? 'not-allowed' : 'pointer',
fontWeight: 700,
color: '#fff',
backgroundColor: color,
borderRadius: s.borderRadius,
padding: s.padding,
fontSize: s.fontSize,
opacity: disabled ? 0.55 : 1,
transform: 'translateY(-1px)',
boxShadow: [
0 ${s.shadowDepth} 0 0 ${shadowColor},
0 16px 28px 0 ${shadowColor}66,
'inset 0 2px 4px 0 rgba(255,255,255,0.35)',
].join(', '),
transition: 'box-shadow 120ms ease, transform 120ms ease',
};
return (
<button
style={baseStyle}
onClick={!disabled ? onClick : undefined}
disabled={disabled}
onMouseDown={e => {
if (!disabled) {
(e.currentTarget as HTMLButtonElement).style.transform = 'translateY(0px)';
(e.currentTarget as HTMLButtonElement).style.boxShadow = [
0 4px 0 0 ${shadowColor},
0 6px 12px 0 ${shadowColor}44,
'inset 0 2px 4px 0 rgba(255,255,255,0.25)',
].join(', ');
}
}}
onMouseUp={e => {
(e.currentTarget as HTMLButtonElement).style.transform = 'translateY(-1px)';
(e.currentTarget as HTMLButtonElement).style.boxShadow = baseStyle.boxShadow as string;
}}
onMouseLeave={e => {
(e.currentTarget as HTMLButtonElement).style.transform = 'translateY(-1px)';
(e.currentTarget as HTMLButtonElement).style.boxShadow = baseStyle.boxShadow as string;
}}
>
{children}
</button>
);
}
`
The press interaction is key — on mousedown` the button drops 4px and the shadow depth shrinks from 8px to 4px. That physical push-down response is half of what makes clay buttons feel so satisfying.
Honestly, wiring the press state via inline style mutations isn't the cleanest pattern. If you're in a production codebase, you'd normally reach for CSS classes or a useState for the pressed state. But for a component you're shipping in a design system, the inline approach works well because it keeps the color values co-located with the shadow math — no context-jumping to find a CSS file.
One more thing — the #66 and #44 appended to shadowColor are hex opacity values. 66 is roughly 40% opacity, 44 is about 27%. Quick trick if you don't want to convert to rgba() every time.
Tailwind Version: Shorter, Composable
If you're on Tailwind CSS v3 or v4, you can do most of this with utility classes plus a small box-shadow override. Tailwind doesn't ship clay shadows by default but the shadow-[...] arbitrary value syntax gets you there fast:
``tsx
// With Tailwind CSS (v3.3+ arbitrary values)
export function ClayButtonTw({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) {
return (
<button
onClick={onClick}
className="
inline-flex items-center justify-center
gap-2 px-7 py-3.5
rounded-[20px] border-none
bg-violet-500 text-white font-bold text-base
-translate-y-px cursor-pointer
shadow-[0_8px_0_0_#5b21b6,0_12px_24px_0_rgba(139,92,246,0.45),inset_0_2px_4px_0_rgba(255,255,255,0.35)]
active:translate-y-0
active:shadow-[0_4px_0_0_#5b21b6,0_6px_12px_0_rgba(139,92,246,0.28),inset_0_2px_4px_0_rgba(255,255,255,0.2)]
transition-[box-shadow,transform] duration-[120ms] ease-in-out
disabled:opacity-50 disabled:cursor-not-allowed
"
>
{children}
</button>
);
}
`
The active: variant handles the press state cleanly. No JavaScript, no event handlers — just CSS. This approach is a better fit if you want the button to work with keyboard navigation too, since :active` fires on Space/Enter as well.
That said, arbitrary shadow values in Tailwind get long fast. If you're creating a full system of clay buttons (primary, danger, success, etc.), define them in tailwind.config.js under theme.extend.boxShadow:
``js
// tailwind.config.js
module.exports = {
theme: {
extend: {
boxShadow: {
'clay-violet': '0 8px 0 0 #5b21b6, 0 12px 24px 0 rgba(139,92,246,0.45), inset 0 2px 4px 0 rgba(255,255,255,0.35)',
'clay-violet-pressed': '0 4px 0 0 #5b21b6, 0 6px 12px 0 rgba(139,92,246,0.28), inset 0 2px 4px 0 rgba(255,255,255,0.2)',
'clay-rose': '0 8px 0 0 #be185d, 0 12px 24px 0 rgba(244,63,94,0.45), inset 0 2px 4px 0 rgba(255,255,255,0.35)',
},
},
},
};
`
Now you're writing shadow-clay-violet active:shadow-clay-violet-pressed` and things stay readable.
Quick aside: the claymorphism hub on Empire UI has pre-built components if you want to skip the from-scratch phase and grab something production-ready. Worth a look before you spend three hours tuning shadow values.
Handling Dark Mode and Accessibility
Clay buttons and dark mode are a tricky combo. The pastel palette that looks great on white backgrounds often turns muddy on dark surfaces. The fix is to dial down the ambient glow opacity and lighten the bottom shadow — not make the button lighter overall.
``css
@media (prefers-color-scheme: dark) {
.clay-button {
box-shadow:
0 8px 0 0 #3730a3,
0 12px 24px 0 rgba(99, 102, 241, 0.3), /* less opacity in dark mode */
inset 0 2px 4px 0 rgba(255, 255, 255, 0.2);
}
}
``
The ambient glow drops from 0.45 to 0.3 and the bottom shadow shifts to a slightly lighter variant. That's usually enough to preserve the 3D read without blowing out the whole dark layout.
For accessibility, you can't rely on the shadow alone to communicate the pressed state — some users have reduced-motion preferences, and others may be on high-contrast mode. Add a focus ring:
``css
.clay-button:focus-visible {
outline: 3px solid #a5b4fc;
outline-offset: 4px;
}
`
The focus-visible` selector keeps the ring off mouse clicks (where it's visually noisy) but shows it on keyboard navigation. Don't skip this — buttons are interactive elements and the WCAG 2.1 AA standard requires a visible focus indicator.
Look, color contrast is also worth checking. Bright clay colors on white text are usually fine, but some mid-range pastels (especially yellow-greens) can drop under the 4.5:1 contrast ratio required for normal text. Run your palette through a contrast checker before shipping.
If you want to see how claymorphism sits alongside other 3D-ish styles, check out what is claymorphism — it gets into the design theory side which actually matters when you're pitching the style to a client or a design lead who doesn't immediately get it.
Animation: Hover Float and Ripple
The base press interaction is table stakes. If you want buttons that actually feel alive, add a subtle hover float:
``css
.clay-button {
transform: translateY(-1px);
transition: box-shadow 120ms ease, transform 120ms ease;
}
.clay-button:hover:not(:disabled) {
transform: translateY(-3px);
box-shadow:
0 10px 0 0 #4a42cc,
0 18px 32px 0 rgba(108, 99, 255, 0.5),
inset 0 2px 4px 0 rgba(255, 255, 255, 0.4);
}
``
Moving from -1px to -3px on hover with a slightly larger ambient glow makes the button feel like it's rising toward you. Keep the duration at 120ms or under — anything slower and the interaction starts to feel laggy rather than playful.
Want a ripple on click? Here's a minimal version using a pseudo-element and a CSS animation — no JS required:
``css
.clay-button {
position: relative;
overflow: hidden;
}
.clay-button::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: rgba(255, 255, 255, 0.25);
transform: scale(0);
opacity: 0;
transition: none;
}
.clay-button:active::after {
animation: clay-ripple 380ms ease-out forwards;
}
@keyframes clay-ripple {
to {
transform: scale(2.5);
opacity: 0;
}
}
`
It's not a true ripple from the click point (for that you'd need a bit of JavaScript to position the origin), but as a flash-of-white on press it reads as satisfying feedback. The overflow: hidden` on the button contains it inside the border-radius.
One more thing — prefers-reduced-motion. Always:
``css
@media (prefers-reduced-motion: reduce) {
.clay-button,
.clay-button::after {
animation: none;
transition: none;
}
}
``
The 3D press effect is visual enough that the button still communicates interactivity without the motion. This covers you for users who've turned off animations at the OS level.
If you're building a whole component library and want more pre-built patterns to work from, browsing Empire UI components is a good shortcut — especially if you're also working with glassmorphism components or other styles in the same project and want a consistent API shape.
Common Pitfalls and How to Fix Them
The most frequent mistake is getting the shadow depth color wrong. Your bottom hard shadow should be a noticeably darker shade of the button color — not a gray, not black with low opacity. Use a color picker to grab a value 20–30 lightness points below your background color. If you use a generic rgba(0,0,0,0.3) you'll get a shadow that looks like a mistake rather than a design choice.
Over-radiusing is real. border-radius: 50px on a wide button doesn't look puffy — it looks like a pill, which is a different thing. The sweet spot is usually between 16px and 28px depending on your padding. Bigger padding → you can afford a bigger radius. Smaller padding → keep the radius closer to 14px.
Layering too many shadows. Three layers is the formula. More than that and the browser is doing extra compositing work for marginal visual gain. Five shadow layers on a button that's probably rendering 20+ times on a page is a micro-perf issue that adds up — especially on mobile where GPU memory is constrained.
Finally, don't forget that buttons need cursor: pointer. It sounds obvious but when you strip out browser default button styles you often lose it. And while you're at it, add user-select: none — clay buttons look great and feel interactive, but nobody wants text selection behavior on a button label.
FAQ
Yes — shadcn/ui buttons take a className prop, so you can apply the shadow utility classes directly. You'll want to override the default border and shadow with your clay values.
Mobile screens often have different gamma rendering. Try bumping your bottom shadow depth from 8px to 10px and increase the ambient glow opacity by about 0.1 — it compensates for the perception difference.
Three shadows on a button is negligible. Problems start when you're animating them on scroll or inside a list with hundreds of items — in those cases, consider using will-change: box-shadow or animating transform instead.
Neumorphism uses two shadows to create a pressed-into-surface effect — the button looks extruded from the background. Claymorphism uses a hard bottom shadow to make the button float above the surface. Completely different reads.