Hover Tilt
A card that tilts towards the pointer under a grained light. I studied the effect on the footer card of cofounder.co and rewrote it from scratch. A spring smooths three numbers: x, y and activation. CSS turns them into the rotation, the glare and the shadow. The grain is a mask of 16 tiled gradients, not an image.
- 2026
- CSS
- Pointer
- Card
Controls
Tilt
Glare
Origin
The effect was studied on the footer card of cofounder.co and rewritten from scratch; no code or image from that site is used.
Source
piece.tsx197 lines
"use client";
import { useEffect, useRef, type CSSProperties } from "react";
import type { DomPieceProps } from "@/entries/types";
import { cn } from "@/lib/utils";
import styles from "./hover-tilt.module.css";
/**
* A card that tilts towards the pointer under a grained light. Studied on the footer card of
* cofounder.co, which ships it as a compiled Svelte custom element with a PNG mask, and rewritten
* from scratch: here the mask is 16 CSS gradients and there is no dependency. From
* clients/alignscale/hover-tilt.
*
* JavaScript owns three numbers (x, y, activation) and springs them; the CSS module derives
* everything visible from them. The loop runs only while a value is still moving.
*/
/** The spring measured on the original: Svelte's motion spring, stepped per 60 Hz frame. */
const STIFFNESS = 0.2;
const DAMPING = 0.8;
const PRECISION = 0.001;
/**
* The best part of the original is the way out. Nothing happens for 200 ms, then both constants
* drop and the card glides home instead of snapping back at the speed it arrived.
*/
const EXIT_DELAY = 200;
const EXIT_STIFFNESS = STIFFNESS * 0.2;
const EXIT_DAMPING = DAMPING * 0.5;
/** Below 30 fps the step is capped, so a stalled tab cannot fling the card. */
const MAX_FRAME = 1000 / 30;
interface Axis {
current: number;
last: number;
target: number;
}
interface Motion {
x: Axis;
y: Axis;
a: Axis;
stiffness: number;
damping: number;
hovering: boolean;
/** Drift clock in ms. It only advances while drifting, so pause and resume do not jump. */
phase: number;
}
const axisAt = (value: number): Axis => ({ current: value, last: value, target: value });
/** One semi-implicit Euler step. Returns true once the axis has come to rest on its target. */
function step(axis: Axis, stiffness: number, damping: number, dt: number): boolean {
const delta = axis.target - axis.current;
const velocity = (axis.current - axis.last) / dt;
const move = (velocity + stiffness * delta - damping * velocity) * dt;
axis.last = axis.current;
if (Math.abs(move) < PRECISION && Math.abs(delta) < PRECISION) {
axis.current = axis.target;
return true;
}
axis.current += move;
return false;
}
export default function HoverTilt({ values, paused }: DomPieceProps) {
const tilt = typeof values.tilt === "number" ? values.tilt : 10;
const glare = typeof values.glare === "number" ? values.glare : 0.6;
const rootRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<HTMLDivElement>(null);
// Outlives the effect below: pausing must leave the card where it is, not send it home.
const motionRef = useRef<Motion | null>(null);
useEffect(() => {
const root = rootRef.current;
const scene = sceneRef.current;
if (!root || !scene) return;
const m = (motionRef.current ??= { x: axisAt(0.5), y: axisAt(0.5), a: axisAt(0), stiffness: STIFFNESS, damping: DAMPING, hovering: false, phase: 0 });
// A touch screen has no hover, so the card drifts on its own and the effect is still there to see.
const touch = window.matchMedia("(hover: none)");
let raf = 0;
let previous: number | null = null;
let exit = 0;
const frame = (now: number) => {
// The first frame has no previous one; and a frame's timestamp can be older than the event that asked for it.
const elapsed = previous === null ? 1000 / 60 : Math.min(Math.max(now - previous, 1), MAX_FRAME);
previous = now;
const drifting = touch.matches && !m.hovering;
if (drifting) {
// Two sines with periods of 9 s and 12.6 s, a ratio of 5 to 7: the path repeats after 63 s.
m.phase += elapsed;
m.x.target = 0.5 + 0.34 * Math.sin(m.phase * 0.0007);
m.y.target = 0.5 + 0.3 * Math.sin(m.phase * 0.0005 + 1.2);
m.a.target = 1;
}
const dt = (60 * elapsed) / 1000;
const restX = step(m.x, m.stiffness, m.damping, dt);
const restY = step(m.y, m.stiffness, m.damping, dt);
const restA = step(m.a, m.stiffness, m.damping, dt);
scene.style.setProperty("--x", m.x.current.toFixed(4));
scene.style.setProperty("--y", m.y.current.toFixed(4));
scene.style.setProperty("--a", m.a.current.toFixed(4));
raf = drifting || !(restX && restY && restA) ? requestAnimationFrame(frame) : 0;
};
const start = () => {
if (paused || raf) return;
previous = null;
raf = requestAnimationFrame(frame);
};
const release = () => {
m.stiffness = EXIT_STIFFNESS;
m.damping = EXIT_DAMPING;
m.x.target = 0.5;
m.y.target = 0.5;
m.a.target = 0;
start();
};
// A fast mouse reports far more often than the screen draws. The handler only moves the target; the spring reads it once a frame.
const onMove = (event: PointerEvent) => {
window.clearTimeout(exit);
// The scene, not the card: the card's box changes as it tilts, the scene's does not.
const box = scene.getBoundingClientRect();
if (!box.width || !box.height) return;
m.hovering = true;
m.stiffness = STIFFNESS;
m.damping = DAMPING;
// Clamped, so the whole stage is live: beyond the card's edge it holds its full tilt.
m.x.target = Math.min(Math.max((event.clientX - box.left) / box.width, 0), 1);
m.y.target = Math.min(Math.max((event.clientY - box.top) / box.height, 0), 1);
m.a.target = 1;
start();
};
const onLeave = () => {
window.clearTimeout(exit);
m.hovering = false;
if (touch.matches) start();
else exit = window.setTimeout(release, EXIT_DELAY);
};
const onTouchChange = () => {
if (m.hovering) return;
if (touch.matches) start();
else release();
};
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("pointercancel", onLeave);
touch.addEventListener("change", onTouchChange);
// Coming back from a pause: if the pointer left meanwhile, its exit timer went with the last cleanup.
if (!m.hovering && !touch.matches && (m.a.target !== 0 || m.a.current !== 0)) release();
else start();
return () => {
cancelAnimationFrame(raf);
window.clearTimeout(exit);
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("pointercancel", onLeave);
touch.removeEventListener("change", onTouchChange);
};
}, [paused]);
const vars = { "--tilt": tilt, "--glare": glare } as CSSProperties;
return (
<div ref={rootRef} aria-hidden="true" className={cn(styles.root, "h-full w-full")} style={vars}>
<div className={styles.frame}>
<div ref={sceneRef} className={styles.scene}>
<div className={cn(styles.card, "rounded-media text-ink")}>
<div className={styles.plate}>
<div className={styles.light} />
</div>
<div className={styles.caption}>
<p className={cn(styles.label, "font-mono text-faint")}>
<span>Lab · Pointer</span>
<span>±{tilt}°</span>
</p>
<p className={cn(styles.title, "display")}>Hover Tilt</p>
</div>
<div className={styles.sheen} />
</div>
</div>
</div>
</div>
);
}hover-tilt.module.css220 lines
/*
Hover Tilt. A card that tilts towards the pointer under a grained light.
JavaScript writes three numbers on .scene and nothing else:
--x, --y pointer position on the card, 0 to 1, after the spring
--a activation, 0 at rest and 1 under the pointer
The controls arrive on .root as --tilt (degrees) and --glare (0 to 1).
Rotation, light, sheen, shadow and the dot shift are all calc() of those five.
*/
/* A size container, so .frame below can ask how tall the stage is. It also carries the theme's light and shade. */
.root {
container-type: size;
/* Light has to be light in both themes, and shade has to be dark. No single token is both. */
--hi: var(--raised);
--lo: var(--ink);
/* How much of each goes into the core, the halo, the shade and the band. */
--core: 80%;
--halo: 45%;
--shade: 22%;
--band: 50%;
}
/* Near-white on a dark plate is a far bigger step than white on a light one, so the dark theme takes less of it. */
:global(.dark) .root {
--hi: var(--ink);
--lo: var(--ground);
--core: 34%;
--halo: 16%;
--shade: 30%;
--band: 24%;
}
/*
The caption pill and the pause button own the bottom 76px of the stage. The frame is a size
container too: the card sizes itself from what is left with cqw and cqh, so a phone gets the
same card, smaller.
*/
.frame {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 16px 16px 76px;
container-type: size;
}
/* A tall stage has room to spare: equal padding puts the card on the true centre. */
@container (min-height: 560px) {
.frame {
padding-block: 76px;
}
}
/*
Perspective lives on the parent and the transform on the card. Set on the card itself, the
vanishing point would travel with it and the depth would flatten.
*/
.scene {
--x: 0.5;
--y: 0.5;
--a: 0;
/* -1 to 1, from the centre. */
--sx: calc(var(--x) * 2 - 1);
--sy: calc(var(--y) * 2 - 1);
/* 300x400 on a desktop stage, smaller where the stage is. --u is one design pixel. */
--h: min(400px, 100cqh, 133.33cqw);
--u: calc(var(--h) / 400);
width: calc(var(--h) * 0.75);
height: var(--h);
perspective: 600px;
}
.card {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: calc(var(--u) * 12);
background: var(--raised);
/* The axes cross: the pointer's y drives rotateX and its x drives rotateY. The side under the pointer comes forward. */
transform: rotateX(calc(var(--sy) * var(--tilt, 10) * 1deg)) rotateY(calc(var(--sx) * var(--tilt, 10) * -1deg)) translateZ(0.01px);
will-change: transform;
/*
The shadow slides the same way as the pointer, so the light reads as coming from the far side.
It fades in with the activation: at rest the card is flat on the stage, a step in lightness only.
*/
box-shadow: calc(var(--sx) * var(--u) * 14) calc(var(--sy) * var(--u) * 7 + var(--u) * 10) calc(var(--u) * 30) calc(var(--u) * -10)
color-mix(in oklch, var(--ink) calc(var(--a) * 14%), transparent);
}
/* The dark theme has no shadows: ink is light there, and a shadow made of it would be a glow. */
:global(.dark) .card {
box-shadow: none;
}
/*
The plate is a mid tone in both themes. Light cannot show on white and shade cannot show on
black; on a mid tone both do, so the glare reads the same way in either theme.
*/
.plate {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
/* Inner radius = outer radius - padding: 24 - 12. */
border-radius: calc(24px - var(--u) * 12);
background: color-mix(in oklch, var(--ink) 9%, var(--surface));
}
/* The site's dot field, as the card's art. It shifts 6px against the pointer, like print under glass. */
.plate::before {
content: "";
position: absolute;
inset: calc(var(--u) * -8);
background-image: radial-gradient(circle at center, color-mix(in oklch, var(--ink) 46%, transparent) 1px, transparent 1.6px);
background-size: max(8px, calc(var(--u) * 12)) max(8px, calc(var(--u) * 12));
/* Mask values are alpha, not paint. */
mask-image: radial-gradient(120% 90% at 68% 42%, #000 0%, rgb(0 0 0 / 0.55) 38%, transparent 78%);
transform: translate(calc(var(--sx) * var(--u) * -6), calc(var(--sy) * var(--u) * -6));
}
/* The text sits above the sheen so it stays sharp. The original cut a hole in its mask for the same reason. */
.caption {
position: relative;
z-index: 1;
padding: calc(var(--u) * 14) calc(var(--u) * 8) calc(var(--u) * 6);
}
.label {
display: flex;
justify-content: space-between;
gap: 1em;
font-size: max(9px, calc(var(--u) * 11));
line-height: 1.4;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.title {
margin-top: calc(var(--u) * 6);
font-size: max(15px, calc(var(--u) * 30));
line-height: 1.05;
font-weight: 500;
}
/* Both glare layers fade in with the activation, scaled by the Glare control. */
.light,
.sheen {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
opacity: calc(var(--a) * var(--glare, 0.6));
}
/*
Layer 1, the grained light, on the plate only: on the white frame its shade would read as holes.
Three stops, as measured: a core under the pointer, a wide halo, and shade towards the far
corner. The shade matters as much as the light: it is what reads as laminate.
*/
.light {
--cell: conic-gradient(from 270deg at 4px 4px, #000 90deg, transparent 0);
--wide: conic-gradient(from 270deg at 8px 4px, #000 90deg, transparent 0);
background-image: radial-gradient(
farthest-corner circle at calc(var(--x) * 100%) calc(var(--y) * 100%),
color-mix(in oklch, var(--hi) var(--core), transparent) 8%,
color-mix(in oklch, var(--hi) var(--halo), transparent) 28%,
color-mix(in oklch, var(--lo) var(--shade), transparent) 90%
);
/*
The signature of the effect. The original masks its light with a 350x475 PNG: a 4px grid with
about 60% of the cells filled at random. The mask stays still while the gradient moves under
it, so the lit cells keep changing: that is the flicker.
Here the PNG is 16 tiled gradients. A conic gradient centred at 4px 4px paints one 4px cell in
the corner of its tile; the mask position moves that cell. Three lattices share the grid: 4
cells in a 16px tile, 5 in a 20px tile, 7 double cells in a 28px tile. Inside a lattice every
row and every column holds exactly one cell, so no row is fuller than another and no bands
show. The union fills about 57% of the grid and repeats every 560px, more than the card.
*/
mask-image:
var(--cell), var(--cell), var(--cell), var(--cell),
var(--cell), var(--cell), var(--cell), var(--cell), var(--cell),
var(--wide), var(--wide), var(--wide), var(--wide), var(--wide), var(--wide), var(--wide);
mask-size:
16px 16px, 16px 16px, 16px 16px, 16px 16px,
20px 20px, 20px 20px, 20px 20px, 20px 20px, 20px 20px,
28px 28px, 28px 28px, 28px 28px, 28px 28px, 28px 28px, 28px 28px, 28px 28px;
/* Column per row: 1 3 0 2, then 2i mod 5, then 3i mod 7. The last two lattices are offset so the three do not start together. */
mask-position:
4px 0, 12px 4px, 0 8px, 8px 12px,
4px 0, 12px 4px, 20px 8px, 8px 12px, 16px 16px,
8px 12px, 20px 16px, 32px 20px, 16px 24px, 28px 28px, 12px 32px, 24px 36px;
}
/*
Layer 2, a smooth band with no grain, over the whole card. The image is 3 times the card and its
centre is kept under the pointer, so the band always passes through it. Its core is the piece's
one use of the accent.
*/
.sheen {
background-image: linear-gradient(
115deg,
transparent 42%,
color-mix(in oklch, var(--hi) var(--band), transparent) 47%,
color-mix(in oklch, var(--accent) 32%, transparent) 50%,
color-mix(in oklch, var(--hi) var(--band), transparent) 53%,
transparent 58%
);
background-size: 300% 300%;
/* Image point under card point c is (c + 2p) / 3. For the centre, 0.5, to sit under x: p = 0.75 - x / 2. */
background-position: calc((0.75 - var(--x) * 0.5) * 100%) calc((0.75 - var(--y) * 0.5) * 100%);
}