Five things doing most of the work.
The clay material
8 shadow stops
One token, used by every surface on the site. Four inset stops model the object's own curvature (a white key light from the top left, a warm core shadow bottom right), and four outer stops carry it away from the page: a 1px contact shadow, a mid shadow, and two wide ambient stops. Flat fills and single-stop shadows are what make claymorphism look like a bevel filter. The gradient stops are hand-picked rather than mixed toward white, because real clay highlights stay warm.
:root {
--clay:
/* the object's own curvature */
inset 0 4px 7px rgba(255,255,255,.72),
inset 3px 0 6px rgba(255,255,255,.34),
inset 0 -7px 13px rgba(74,59,54,.15),
inset -4px -2px 9px rgba(74,59,54,.09),
/* and its distance from the page */
0 1px 1px rgba(74,59,54,.09),
0 5px 9px -3px rgba(74,59,54,.15),
0 14px 22px -8px rgba(74,59,54,.17),
0 30px 48px -16px rgba(74,59,54,.13);
}
The squish
scale + shadow shift
Pressing soft clay does two things at once: it spreads wider and flatter, and it gets closer to the surface it's resting on. So the hover state scales x up and y down, nudges the object down a few pixels, and swaps in a shadow set with tighter, shorter stops. Doing only the transform is the common mistake. The shadow is what sells contact. The overshoot in the easing gives it the wobble back.
.btn {
transform: scale(1);
box-shadow: var(--clay);
transition: transform .42s var(--squish),
box-shadow .42s var(--squish);
}
.btn:hover {
/* wider, shorter, closer to the table */
transform: scale(1.055, .9) translateY(3px);
box-shadow: var(--clay-squish);
}
.btn:active { transform: scale(1.08, .84) translateY(5px); }
/* day cells go the other way: they plump up */
.day:hover { transform: scale(1.14) translateY(-4px);
box-shadow: var(--clay-plump); }
Compositing the generated plate
the money shot
The generated images have a lit field, not a flat fill, and it's warmer than the page. Butting them against #FFF4EC leaves a visible rectangle. The fix is a scrim: a stack of gradients painted over the image in the exact page colour, dissolving its edges into the layout. The radial stop values are tuned against the image's real subject box (measured at x 22.5–90%, y 31–80%) so the fade reaches zero before it touches any clay. The 0-alpha stops spell out the page colour rather than using transparent.
.stage-scrim { /* sits over the img, inset 0 */
background:
/* dissolve everything outside the subject box */
radial-gradient(ellipse 74% 62% at 56% 55%,
rgba(255,244,236,0) 55%,
rgba(255,244,236,.5) 80%,
var(--bg) 100%),
/* four-sided insurance: the radial can't reach corners */
linear-gradient(to right, var(--bg) 0%, rgba(255,244,236,0) 16%),
linear-gradient(to left, var(--bg) 0%, rgba(255,244,236,0) 14%),
linear-gradient(to bottom, var(--bg) 0%, rgba(255,244,236,0) 15%),
linear-gradient(to top, var(--bg) 6%, rgba(255,244,236,0) 17%);
}
Blob geometry
8-value radii
Nothing on the page uses a symmetric radius. Every clay object gets the eight-value border-radius form with each corner a few pixels off its neighbour, which is the difference between "rounded rectangle" and "pinched by hand". The ambient background shapes take the same idea further and animate between radius sets, so they slowly morph while rotating. They run at 13% opacity, because the compositing above only works while the page behind the plate stays the exact cream the scrim fades to.
/* buttons: irregular by a few px per corner */
.blob-a { border-radius: 34px 30px 33px 37px / 33px 37px 30px 34px; }
/* ambient shapes: morph between radius sets while spinning */
@keyframes morph {
0% { border-radius: 58% 42% 46% 54% / 48% 56% 44% 52%; }
50% { border-radius: 62% 38% 40% 60% / 42% 62% 38% 58%; }
100% { border-radius: 52% 48% 38% 62% / 44% 52% 48% 56%; }
}
.ablob { animation: morph var(--md) var(--soft) infinite alternate,
spin var(--sd) linear infinite; }
Wobble-in choreography
IntersectionObserver
Everything enters with a slight rotation that settles to zero on a spring, and the rotation direction alternates so a row of cards lands like hand-placed objects instead of a synchronised grid. Reveals fire once and unobserve. The floating chips need three nested elements, because the parallax, the entrance and the idle bob each want to own transform, and the last one to write wins. One transform per layer is the whole trick.
[data-reveal] {
opacity: 0;
transform: translateY(26px) scale(.965) rotate(var(--rot));
transition: opacity .68s var(--soft) var(--d),
transform .88s var(--spring) var(--d);
}
[data-reveal].is-in { opacity: 1; transform: none; }
/* fires once, then stops watching */
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
e.target.classList.add('is-in');
io.unobserve(e.target);
});
}, { rootMargin: '0px 0px -9% 0px', threshold: .12 });