Card flipWeb · App
Target
Implement the "Card lift on hover" effect in vanilla HTML/CSS/JS. Context: A card rises a few pixels and its shadow deepens on hover, settling back on leave. Trigger: the pointer hovers the element (fall back to focus on touch devices). Timeline: time-based (duration + easing). Motion tokens (normal): - translateY (card): 0 → -4px - opacity (shadow layer): 0 → 1 - duration: 200ms - easing: cubic-bezier(0.2, 0.8, 0.2, 1) - note: leave uses 300ms ease-out so the drop is softer than the lift Motion tokens (prefers-reduced-motion): - opacity (shadow layer): 0 → 1 - duration: 150ms - easing: ease-out Technique: prefer pure CSS transitions/animations; use JS only to toggle classes. Acceptance: - animate only transform and opacity unless the spec lists other properties - no layout shift; content is visible without the animation - when prefers-reduced-motion is set, use the reduced tokens only - box-shadow itself is never animated; a pre-rendered shadow layer fades in - focus-visible triggers the same lift for keyboard users
Generated from the spec. Prompts differ by target name only; the tokens are the source of truth.
demo.html
<div class="fx-card-hover-lift" data-fx-root> <div class="fx-card" tabindex="0" role="link" aria-label="Example card"><div class="fx-media"></div><div class="fx-body"><div class="fx-t"></div><div class="fx-s"></div></div></div> </div>
demo.css
.fx-card-hover-lift { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.fx-card-hover-lift .fx-card { position: relative; display: block; width: 50%; max-width: 260px; border-radius: 14px; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.06), 0 0 0 1px var(--fx-muted, #e4e4e7); transition: transform 300ms ease-out; outline-offset: 4px; }
.fx-card-hover-lift .fx-card::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: 0 18px 40px rgba(24,24,27,.16); opacity: 0; transition: opacity 300ms ease-out; pointer-events: none; }
.fx-card-hover-lift .fx-card:hover, .fx-card-hover-lift .fx-card:focus-visible, .fx-card-hover-lift .fx-card.is-hover { transform: translateY(-4px); transition-duration: 200ms; transition-timing-function: cubic-bezier(.2, .8, .2, 1); }
.fx-card-hover-lift .fx-card:hover::after, .fx-card-hover-lift .fx-card:focus-visible::after, .fx-card-hover-lift .fx-card.is-hover::after { opacity: 1; transition-duration: 200ms; }
.fx-card-hover-lift .fx-media { height: 90px; border-radius: 14px 14px 0 0; background: linear-gradient(135deg, #c7d2fe, #818cf8); }
.fx-card-hover-lift .fx-body { display: flex; flex-direction: column; gap: 8px; padding: 14px; }
.fx-card-hover-lift .fx-t { height: 12px; width: 60%; border-radius: 4px; background: var(--fx-strong, #d4d4d8); }
.fx-card-hover-lift .fx-s { height: 8px; width: 85%; border-radius: 4px; background: var(--fx-muted, #e4e4e7); }
@media (prefers-reduced-motion: reduce) { .fx-card-hover-lift .fx-card:hover, .fx-card-hover-lift .fx-card.is-hover { transform: none; } }
demo.js
export default function mount(root) {
const card = root.querySelector('.fx-card');
let t1, t2;
return {
play() { clearTimeout(t1); clearTimeout(t2); t1 = setTimeout(() => card.classList.add('is-hover'), 300); t2 = setTimeout(() => card.classList.remove('is-hover'), 1300); },
stop() { clearTimeout(t1); clearTimeout(t2); card.classList.remove('is-hover'); },
};
}
Inspired by tobiasahlin.com — animate a pseudo-element's opacity instead of box-shadow. The preview is an original re-creation; the effect name belongs to its author.
Asymmetric timing (200ms up, 300ms down) is the whole trick; symmetric hover transitions feel mechanical. The shadow lives on `::after` and fades, because animating `box-shadow` repaints every frame.
Added 2026-11-09 · Report an issue