Landing Page Animations — CSS, Vanilla JS, and React + Framer Motion
Senior guide to landing-page motion: page-load orchestration, scroll reveal, stagger, parallax, gestures, and route transitions — with CSS, vanilla JS, and Framer Motion patterns plus library picks.
Animation should guide attention, not decorate {Animation nên dẫn sự chú ý, không phải trang trí}
A landing page animation earns its place when it answers a UX question {Animation trên landing page chỉ xứng đáng khi nó trả lời một câu hỏi UX}: where should the eye go first {mắt nên đi đâu trước}, what changed after an action {cái gì đổi sau một hành động}, or how far through the story the visitor has scrolled {khách đã đi được bao xa trong câu chuyện}. Motion that does not clarify hierarchy or feedback is decoration — and decoration on the critical path costs performance and accessibility budget {Chuyển động không làm rõ thứ bậc hay phản hồi là trang trí — và trang trí trên critical path tốn ngân sách hiệu năng lẫn khả năng tiếp cận}.
This post maps ten landing-page effects across three implementation layers {Bài này map mười hiệu ứng landing page qua ba lớp triển khai}: modern CSS (scroll-driven timelines, @starting-style, View Transitions) {CSS hiện đại (scroll-driven timeline, @starting-style, View Transitions)}, vanilla JS (IntersectionObserver, WAAPI, pointer events) {vanilla JS (IntersectionObserver, WAAPI, pointer events)}, and React + Framer Motion (declarative variants, gestures, layout) {React + Framer Motion (variants khai báo, gestures, layout)}. For each effect you get working code, a library-vs-native note, and a link to the live demo anchor {Với mỗi hiệu ứng bạn có code chạy được, ghi chú thư viện vs native, và link tới anchor demo trực tiếp}.
The default rule throughout {Quy tắc mặc định xuyên suốt}: animate transform and opacity only {chỉ animate transform và opacity}, respect prefers-reduced-motion, and lazy-init anything below the fold {tôn trọng prefers-reduced-motion, và lazy-init mọi thứ dưới fold}.
Open the full demo {Mở demo đầy đủ}: /tools/landing-animations-demo/.
Page load orchestration {Điều phối khi load trang}
What it is {Là gì}: a choreographed entrance sequence when the page first paints — hero headline, subcopy, CTA, and supporting visuals appear in a deliberate order rather than all at once {một chuỗi vào có đạo diễn khi trang paint lần đầu — headline hero, subcopy, CTA và visual phụ xuất hiện theo thứ tự có chủ đích thay vì cùng lúc}. When to use it {Khi nào dùng}: above-the-fold hero sections where you need to establish brand tone without blocking interaction {hero above-the-fold khi cần thiết lập tone thương hiệu mà không chặn tương tác}. Keep total orchestration under ~800 ms and never delay the primary CTA past first paint + 400 ms {Giữ tổng điều phối dưới ~800 ms và không trì hoãn CTA chính quá first paint + 400 ms}.
See it live {Xem trực tiếp}: demo: page load.
CSS approach {Cách CSS}
Use @keyframes with staggered animation-delay and animation-fill-mode: both so elements pre-fill their start state during the wait {Dùng @keyframes với animation-delay stagger và animation-fill-mode: both để phần tử điền trước trạng thái đầu trong lúc chờ}:
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hero [data-enter] {
animation: rise-in 600ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero [data-enter='1'] { animation-delay: 0ms; }
.hero [data-enter='2'] { animation-delay: 80ms; }
.hero [data-enter='3'] { animation-delay: 160ms; }
.hero [data-enter='4'] { animation-delay: 240ms; }
@media (prefers-reduced-motion: reduce) {
.hero [data-enter] {
animation: none;
opacity: 1;
transform: none;
}
}
Vanilla JS approach {Cách vanilla JS}
When you need to wait for fonts or a Lottie asset before starting the sequence, gate the animation with a class toggle {Khi cần đợi font hoặc asset Lottie trước khi bắt đầu chuỗi, chặn animation bằng toggle class}:
const hero = document.querySelector('.hero');
async function orchestrateEntrance() {
if (document.fonts?.ready) await document.fonts.ready;
hero?.classList.add('is-ready');
}
orchestrateEntrance();
Pair with CSS that only runs animations when .is-ready is present {Kết hợp CSS chỉ chạy animation khi có .is-ready}. For imperative control, WAAPI gives you finished promises to chain steps {Để điều khiển mệnh lệnh, WAAPI cho promise finished để xâu chuỗi bước}:
const headline = document.querySelector('.hero__headline');
headline?.animate(
[{ opacity: 0, transform: 'translateY(24px)' }, { opacity: 1, transform: 'translateY(0)' }],
{ duration: 600, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', fill: 'forwards' }
).finished.then(() => {
document.querySelector('.hero__cta')?.classList.add('is-visible');
});
React + Framer Motion approach {Cách React + Framer Motion}
Define a parent container variant with staggerChildren and let each child inherit hidden → visible {Định nghĩa variant container cha với staggerChildren và để mỗi con kế thừa hidden → visible}:
import { motion, useReducedMotion } from 'framer-motion';
const container = {
hidden: {},
visible: {
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
},
};
const item = {
hidden: { opacity: 0, y: 24 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
},
};
export function Hero() {
const reduceMotion = useReducedMotion();
return (
<motion.section
className="hero"
variants={container}
initial="hidden"
animate="visible"
transition={reduceMotion ? { duration: 0 } : undefined}
>
<motion.h1 variants={item}>Ship motion that respects users</motion.h1>
<motion.p variants={item}>CSS, JS, or Framer Motion — pick by constraint.</motion.p>
<motion.button variants={item} type="button">Get started</motion.button>
</motion.section>
);
}
Library vs plain CSS {Thư viện vs CSS thuần}
Pure CSS stagger via animation-delay is zero-JS and compositor-friendly {Stagger CSS thuần qua animation-delay không cần JS và thân compositor}. Reach for Framer Motion when the sequence depends on React state (e.g. wait for data before animating) or you need to interrupt/reverse mid-flight {Chọn Framer Motion khi chuỗi phụ thuộc state React (vd đợi data trước khi animate) hoặc cần ngắt/đảo giữa chừng}.
Scroll reveal {Hiện khi scroll}
What it is {Là gì}: elements fade or slide into view as they enter the viewport {phần tử fade hoặc trượt vào khi lọt viewport}. When to use it {Khi nào dùng}: feature grids, testimonial cards, and section headers below the fold — anywhere you want progressive disclosure without hiding content from crawlers {lưới tính năng, thẻ testimonial, header section dưới fold — bất cứ đâu cần lộ dần mà không ẩn content khỏi crawler}.
See it live {Xem trực tiếp}: demo: scroll reveal.
CSS approach {Cách CSS}
Scroll-driven view() timelines tie reveal progress to element visibility — no IntersectionObserver required {Timeline view() scroll-driven gắn tiến trình reveal với visibility phần tử — không cần IntersectionObserver}:
@keyframes reveal-up {
from {
opacity: 0;
transform: translateY(32px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.reveal {
animation: reveal-up linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
@supports not (animation-timeline: view()) {
.reveal {
opacity: 1;
transform: none;
}
}
Vanilla JS approach {Cách vanilla JS}
IntersectionObserver remains the portable fallback {IntersectionObserver vẫn là fallback portable}:
const reveals = document.querySelectorAll('.reveal');
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
}
}
},
{ rootMargin: '0px 0px -10% 0px', threshold: 0.15 }
);
reveals.forEach((el) => observer.observe(el));
.reveal {
opacity: 0;
transform: translateY(32px);
transition: opacity 500ms ease, transform 500ms cubic-bezier(0.22, 1, 0.36, 1);
}
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
React + Framer Motion approach {Cách React + Framer Motion}
whileInView with viewport.once is the idiomatic one-liner {whileInView với viewport.once là one-liner đúng chuẩn}:
import { motion } from 'framer-motion';
export function FeatureCard({ title, body }: { title: string; body: string }) {
return (
<motion.article
className="feature-card"
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3, margin: '0px 0px -10% 0px' }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
>
<h3>{title}</h3>
<p>{body}</p>
</motion.article>
);
}
Library vs plain CSS {Thư viện vs CSS thuần}
Prefer animation-timeline: view() when baseline browser support matches your audience {Ưu tiên animation-timeline: view() khi baseline trình duyệt khớp audience}. Use Framer Motion when reveals depend on React list keys, conditional rendering, or shared layout context {Dùng Framer Motion khi reveal phụ thuộc list key React, render có điều kiện, hoặc layout context chung}.
Staggered children {Stagger các phần tử con}
What it is {Là gì}: a parent container triggers its children in sequence with a fixed or dynamic offset {container cha kích hoạt các con theo thứ tự với offset cố định hoặc động}. When to use it {Khi nào dùng}: logo clouds, pricing tiers, nav items, and any grid where simultaneous motion feels chaotic {logo cloud, bậc giá, item nav, và mọi lưới mà chuyển động đồng thời cảm giác hỗn loạn}.
See it live {Xem trực tiếp}: demo: stagger.
CSS approach {Cách CSS}
Negative animation-delay on :nth-child is the zero-dependency pattern {animation-delay âm trên :nth-child là pattern không phụ thuộc}:
@keyframes pop-in {
from {
opacity: 0;
transform: scale(0.92);
}
to {
opacity: 1;
transform: scale(1);
}
}
.stagger-grid > * {
animation: pop-in 400ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.stagger-grid > *:nth-child(1) { animation-delay: 0ms; }
.stagger-grid > *:nth-child(2) { animation-delay: 60ms; }
.stagger-grid > *:nth-child(3) { animation-delay: 120ms; }
.stagger-grid > *:nth-child(4) { animation-delay: 180ms; }
For scroll-triggered stagger, combine view() with per-item animation-range offsets {Với stagger kích scroll, kết hợp view() và offset animation-range từng item}:
.stagger-grid > * {
animation: pop-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
.stagger-grid > *:nth-child(2) { animation-range: entry 5% entry 100%; }
.stagger-grid > *:nth-child(3) { animation-range: entry 10% entry 100%; }
Vanilla JS approach {Cách vanilla JS}
When child count is dynamic, compute delay in JS and use WAAPI or CSS custom properties {Khi số con động, tính delay trong JS và dùng WAAPI hoặc custom property CSS}:
const grid = document.querySelector('.stagger-grid');
grid?.querySelectorAll(':scope > *').forEach((child, index) => {
child.style.setProperty('--stagger-delay', `${index * 60}ms`);
});
.stagger-grid > * {
animation: pop-in 400ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: var(--stagger-delay, 0ms);
}
React + Framer Motion approach {Cách React + Framer Motion}
Parent staggerChildren + child variants is the canonical pattern {staggerChildren ở cha + variant con là pattern chuẩn}:
import { motion } from 'framer-motion';
const gridVariants = {
hidden: {},
show: {
transition: { staggerChildren: 0.06, delayChildren: 0.05 },
},
};
const cellVariants = {
hidden: { opacity: 0, scale: 0.92 },
show: { opacity: 1, scale: 1 },
};
export function LogoCloud({ logos }: { logos: string[] }) {
return (
<motion.ul
className="stagger-grid"
variants={gridVariants}
initial="hidden"
whileInView="show"
viewport={{ once: true, amount: 0.2 }}
>
{logos.map((logo) => (
<motion.li key={logo} variants={cellVariants}>
<img src={logo} alt="" loading="lazy" />
</motion.li>
))}
</motion.ul>
);
}
Use staggerDirection: -1 for reverse order and staggerChildren as a function for dynamic lists {Dùng staggerDirection: -1 cho thứ tự ngược và staggerChildren dạng function cho list động}.
Library vs plain CSS {Thư viện vs CSS thuần}
CSS :nth-child stagger is perfect for static markup {Stagger :nth-child CSS lý tưởng cho markup tĩnh}. Framer Motion wins when items mount/unmount (filtered lists) or stagger must sync with AnimatePresence exits {Framer Motion thắng khi item mount/unmount (list lọc) hoặc stagger phải sync với exit AnimatePresence}.
Hero text reveal {Reveal text hero}
What it is {Là gì}: headline copy animates in word-by-word, line-by-line, or via a clip/mask wipe {copy headline animate từng từ, từng dòng, hoặc qua clip/mask wipe}. When to use it {Khi nào dùng}: hero headlines with ≤12 words — enough drama to set tone, not so much that LCP suffers {headline hero ≤12 từ — đủ drama đặt tone, không đến mức ảnh hưởng LCP}. Always keep the full text in the DOM for SEO and screen readers {Luôn giữ full text trong DOM cho SEO và screen reader}.
See it live {Xem trực tiếp}: demo: text reveal.
CSS approach {Cách CSS}
Split lines with <span class="line"> wrappers (server-side or build step) and stagger each line {Tách dòng bằng wrapper <span class="line"> (server-side hoặc build step) và stagger từng dòng}:
@keyframes line-up {
from {
opacity: 0;
transform: translateY(1.1em);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hero-title .line {
display: block;
overflow: hidden;
animation: line-up 700ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero-title .line:nth-child(2) { animation-delay: 100ms; }
.hero-title .line:nth-child(3) { animation-delay: 200ms; }
For a clip reveal, animate clip-path on a wrapper (still compositor-friendly in modern browsers) {Với clip reveal, animate clip-path trên wrapper (vẫn thân compositor trên trình duyệt hiện đại)}:
@keyframes clip-reveal {
from { clip-path: inset(0 0 100% 0); }
to { clip-path: inset(0 0 0 0); }
}
.hero-title .line-inner {
animation: clip-reveal 800ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
Vanilla JS approach {Cách vanilla JS}
Split text into spans at runtime only if you accept the hydration/SEO trade-off on SSR pages — prefer build-time splitting for static sites {Tách text thành span lúc runtime chỉ khi chấp nhận trade-off hydration/SEO trên trang SSR — ưu tiên tách lúc build cho static site}:
function splitWords(el) {
const text = el.textContent ?? '';
el.textContent = '';
el.setAttribute('aria-label', text);
text.split(/\s+/).forEach((word, i) => {
const span = document.createElement('span');
span.className = 'word';
span.textContent = word;
span.style.setProperty('--word-delay', `${i * 40}ms`);
el.append(span);
if (i < text.split(/\s+/).length - 1) el.append(document.createTextNode(' '));
});
}
.hero-title .word {
display: inline-block;
opacity: 0;
transform: translateY(0.5em);
animation: line-up 500ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: var(--word-delay);
}
React + Framer Motion approach {Cách React + Framer Motion}
Map words to motion.span with index-based custom delay or parent stagger {Map từng từ sang motion.span với delay custom theo index hoặc stagger cha}:
import { motion } from 'framer-motion';
const wordContainer = {
hidden: {},
visible: {
transition: { staggerChildren: 0.04, delayChildren: 0.15 },
},
};
const wordItem = {
hidden: { opacity: 0, y: '0.5em' },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
},
};
export function HeroTitle({ text }: { text: string }) {
const words = text.split(' ');
return (
<motion.h1
className="hero-title"
variants={wordContainer}
initial="hidden"
animate="visible"
aria-label={text}
>
{words.map((word, i) => (
<motion.span key={`${word}-${i}`} variants={wordItem} className="word">
{word}{i < words.length - 1 ? '\u00A0' : ''}
</motion.span>
))}
</motion.h1>
);
}
Library vs plain CSS {Thư viện vs CSS thuần}
Pre-split HTML + CSS is the most performant and accessible path for static Astro/SSG pages {HTML tách sẵn + CSS là đường hiệu năng và a11y tốt nhất cho trang Astro/SSG tĩnh}. Framer Motion simplifies dynamic headlines (i18n strings, A/B copy) without manual delay math {Framer Motion đơn giản hoá headline động (chuỗi i18n, copy A/B) mà không cần tính delay thủ công}.
Scroll parallax {Parallax khi scroll}
What it is {Là gì}: foreground and background layers move at different rates during scroll, creating depth {lớp foreground và background di chuyển với tốc độ khác nhau khi scroll, tạo chiều sâu}. When to use it {Khi nào dùng}: hero illustrations, decorative blobs, and section dividers — never on body text or interactive controls {minh hoạ hero, blob trang trí, divider section — không bao giờ trên body text hay control tương tác}.
See it live {Xem trực tiếp}: demo: parallax.
CSS approach {Cách CSS}
Bind translate progress to scroll with animation-timeline: scroll() {Gắn tiến trình translate với scroll qua animation-timeline: scroll()}:
@keyframes parallax-bg {
from { transform: translateY(-8%); }
to { transform: translateY(8%); }
}
.hero-bg {
animation: parallax-bg linear;
animation-timeline: scroll(root);
will-change: transform;
}
.hero-fg {
animation: parallax-bg linear reverse;
animation-timeline: scroll(root);
}
Vanilla JS approach {Cách vanilla JS}
If you need multi-layer speed factors and must support older browsers, read scroll once per frame via requestAnimationFrame and write only transform {Nếu cần hệ số tốc độ nhiều lớp và phải hỗ trợ trình duyệt cũ, đọc scroll một lần mỗi frame qua requestAnimationFrame và chỉ ghi transform}:
const layers = document.querySelectorAll('[data-parallax]');
let ticking = false;
function updateParallax() {
const scrollY = window.scrollY;
layers.forEach((layer) => {
const speed = Number(layer.getAttribute('data-parallax')) || 0.2;
const y = scrollY * speed;
layer.style.transform = `translate3d(0, ${y}px, 0)`;
});
ticking = false;
}
window.addEventListener(
'scroll',
() => {
if (!ticking) {
ticking = true;
requestAnimationFrame(updateParallax);
}
},
{ passive: true }
);
React + Framer Motion approach {Cách React + Framer Motion}
useScroll + useTransform maps scroll progress to any motion value {useScroll + useTransform map tiến trình scroll sang mọi motion value}:
import { motion, useScroll, useTransform } from 'framer-motion';
import { useRef } from 'react';
export function ParallaxHero() {
const ref = useRef<HTMLElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start start', 'end start'],
});
const bgY = useTransform(scrollYProgress, [0, 1], ['-8%', '8%']);
const fgY = useTransform(scrollYProgress, [0, 1], ['0%', '-15%']);
return (
<section ref={ref} className="parallax-hero">
<motion.div className="hero-bg" style={{ y: bgY }} aria-hidden="true" />
<motion.div className="hero-fg" style={{ y: fgY }}>
<h1>Depth without layout thrash</h1>
</motion.div>
</section>
);
}
Library vs plain CSS {Thư viện vs CSS thuần}
CSS scroll-driven parallax is compositor-native and the first choice in 2026 {Parallax scroll-driven CSS native compositor và là lựa chọn đầu tiên năm 2026}. Framer Motion useScroll shines when parallax is tied to a React scroll container (modal, carousel) rather than the document {useScroll Framer Motion mạnh khi parallax gắn scroll container React (modal, carousel) thay vì document}.
Scroll progress and sticky pin {Tiến độ scroll và sticky pin}
What it is {Là gì}: a progress indicator (bar, ring, or section dots) tracks scroll position; sticky pin keeps a element fixed while content scrolls past it {chỉ báo tiến độ (thanh, vòng, chấm section) theo vị trí scroll; sticky pin giữ phần tử cố định trong khi content cuộn qua}. When to use it {Khi nào dùng}: long-form landing pages, product storytelling, and docs-style marketing pages {landing page dài, storytelling sản phẩm, trang marketing kiểu docs}.
See it live {Xem trực tiếp}: demo: scroll progress.
CSS approach {Cách CSS}
A reading progress bar is a one-rule scroll timeline {Thanh tiến độ đọc là scroll timeline một dòng}:
@keyframes grow-x {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.reading-progress {
position: fixed;
inset: 0 0 auto 0;
height: 3px;
transform-origin: left center;
background: var(--color-accent, #c8ff00);
animation: grow-x linear;
animation-timeline: scroll(root);
z-index: 50;
}
Sticky pin with scroll-driven scale {Sticky pin với scale scroll-driven}:
.sticky-pin {
position: sticky;
top: 4rem;
}
.sticky-pin__visual {
animation: grow-x linear reverse;
animation-timeline: view();
animation-range: cover 0% cover 100%;
}
Vanilla JS approach {Cách vanilla JS}
When you need exact pixel math (e.g. pin until section N ends), combine getBoundingClientRect with a throttled rAF loop — but only for the pinned element, not the whole page {Khi cần toán pixel chính xác (vd pin tới hết section N), kết hợp getBoundingClientRect với vòng rAF throttle — nhưng chỉ cho phần tử pin, không phải cả trang}:
const bar = document.querySelector('.reading-progress');
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
function updateProgress() {
const progress = docHeight > 0 ? window.scrollY / docHeight : 0;
bar?.style.setProperty('--progress', String(progress));
}
window.addEventListener('scroll', updateProgress, { passive: true });
updateProgress();
.reading-progress {
transform: scaleX(var(--progress, 0));
transform-origin: left center;
}
React + Framer Motion approach {Cách React + Framer Motion}
useScroll on document or a ref gives scrollYProgress as a 0–1 motion value for SVG or width {useScroll trên document hoặc ref cho scrollYProgress dạng motion value 0–1 cho SVG hoặc width}:
import { motion, useScroll, useSpring } from 'framer-motion';
export function ReadingProgress() {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, { stiffness: 100, damping: 30, restDelta: 0.001 });
return (
<motion.div
className="reading-progress"
style={{ scaleX, transformOrigin: '0% 50%' }}
aria-hidden="true"
/>
);
}
For sticky sections with animated children, wrap the pinned block in motion.div and drive opacity from the same scrollYProgress segment {Với section sticky có con animate, bọc block pin trong motion.div và drive opacity từ segment scrollYProgress cùng đó}.
Library vs plain CSS {Thư viện vs CSS thuần}
CSS scroll() timelines are the correct default for progress bars {Timeline scroll() CSS là default đúng cho progress bar}. Add Framer Motion when the indicator morphs shape (bar → circle) via useTransform ranges or springs {Thêm Framer Motion khi indicator đổi hình (thanh → vòng) qua range useTransform hoặc spring}.
Hover micro-interactions {Micro-interaction hover}
What it is {Là gì}: magnetic buttons that follow the cursor, 3D tilt cards, and subtle scale/glow on hover {nút magnetic bám con trỏ, thẻ tilt 3D, và scale/glow nhẹ khi hover}. When to use it {Khi nào dùng}: primary CTA, pricing cards, and portfolio tiles — one or two per viewport, not every link {CTA chính, thẻ giá, tile portfolio — một hoặc hai mỗi viewport, không phải mọi link}.
See it live {Xem trực tiếp}: demo: hover micro.
CSS approach {Cách CSS}
Simple lift + shadow on :hover with transition — state-driven, compositor-safe {Lift + shadow đơn giản trên :hover với transition — theo state, an toàn compositor}:
.tilt-card {
transform: perspective(800px) rotateX(0deg) rotateY(0deg) scale(1);
transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 300ms ease;
}
.tilt-card:hover {
transform: perspective(800px) rotateX(2deg) rotateY(-3deg) scale(1.02);
box-shadow: 0 24px 48px rgb(0 0 0 / 0.35);
}
True pointer-tracking tilt requires JS or @property driven custom properties — CSS alone cannot read cursor position {Tilt bám con trỏ thật cần JS hoặc custom property driven @property — CSS thuần không đọc được vị trí con trỏ}.
Vanilla JS approach {Cách vanilla JS}
Pointer move updates CSS variables; reset on leave {Pointer move cập nhật biến CSS; reset khi rời}:
const card = document.querySelector('.tilt-card');
card?.addEventListener('pointermove', (event) => {
const rect = card.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width - 0.5;
const y = (event.clientY - rect.top) / rect.height - 0.5;
card.style.setProperty('--rx', `${y * -12}deg`);
card.style.setProperty('--ry', `${x * 12}deg`);
});
card?.addEventListener('pointerleave', () => {
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});
.tilt-card {
transform: perspective(800px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 150ms ease-out;
}
Magnetic button — translate toward cursor within a radius {Nút magnetic — translate về phía con trỏ trong bán kính}:
const btn = document.querySelector('.magnetic-btn');
btn?.addEventListener('pointermove', (event) => {
const rect = btn.getBoundingClientRect();
const dx = event.clientX - (rect.left + rect.width / 2);
const dy = event.clientY - (rect.top + rect.height / 2);
btn.style.transform = `translate(${dx * 0.25}px, ${dy * 0.25}px)`;
});
btn?.addEventListener('pointerleave', () => {
btn.style.transform = 'translate(0, 0)';
});
React + Framer Motion approach {Cách React + Framer Motion}
whileHover and whileTap for declarative scale; useMotionValue + useSpring for magnetic pull {whileHover và whileTap cho scale khai báo; useMotionValue + useSpring cho lực magnetic}:
import { motion, useMotionValue, useSpring } from 'framer-motion';
import { useRef, type PointerEvent } from 'react';
export function MagneticButton({ children }: { children: string }) {
const ref = useRef<HTMLButtonElement>(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
const springX = useSpring(x, { stiffness: 150, damping: 15 });
const springY = useSpring(y, { stiffness: 150, damping: 15 });
function handleMove(event: PointerEvent<HTMLButtonElement>) {
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
x.set((event.clientX - (rect.left + rect.width / 2)) * 0.3);
y.set((event.clientY - (rect.top + rect.height / 2)) * 0.3);
}
function handleLeave() {
x.set(0);
y.set(0);
}
return (
<motion.button
ref={ref}
className="magnetic-btn"
style={{ x: springX, y: springY }}
onPointerMove={handleMove}
onPointerLeave={handleLeave}
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.97 }}
type="button"
>
{children}
</motion.button>
);
}
Library vs plain CSS {Thư viện vs CSS thuần}
CSS :hover transitions cover 80% of landing micro-interactions {Transition :hover CSS cover 80% micro-interaction landing}. Framer Motion reduces boilerplate for spring physics and pairs naturally with layout on sibling cards {Framer Motion giảm boilerplate cho vật lý spring và kết hợp tự nhiên với layout trên thẻ anh em}.
Gestures {Cử chỉ}
What it is {Là gì}: drag, press, and tap feedback on sliders, carousels, swipeable cards, and dismissible panels {phản hồi drag, press và tap trên slider, carousel, thẻ swipe, panel dismiss}. When to use it {Khi nào dùng}: mobile-first landing sections where touch is the primary input {section landing mobile-first khi touch là input chính}.
See it live {Xem trực tiếp}: demo: gestures.
CSS approach {Cách CSS}
:active scale and touch-action hints cover basic press feedback {Scale :active và gợi ý touch-action cover phản hồi press cơ bản}:
.swipe-card {
touch-action: pan-y;
transition: transform 150ms ease;
}
.swipe-card:active {
transform: scale(0.98);
}
CSS cannot natively track drag offset — that requires JS or a library {CSS không track offset drag native — cần JS hoặc thư viện}.
Vanilla JS approach {Cách vanilla JS}
Pointer events with capture give unified mouse/touch/pen handling {Pointer event với capture cho xử lý thống nhất mouse/touch/pen}:
const track = document.querySelector('.carousel-track');
let startX = 0;
let currentX = 0;
let dragging = false;
track?.addEventListener('pointerdown', (event) => {
dragging = true;
startX = event.clientX;
track.setPointerCapture(event.pointerId);
});
track?.addEventListener('pointermove', (event) => {
if (!dragging) return;
currentX = event.clientX - startX;
track.style.transform = `translateX(${currentX}px)`;
});
track?.addEventListener('pointerup', () => {
dragging = false;
const threshold = 80;
if (currentX < -threshold) track.dispatchEvent(new CustomEvent('carousel-next'));
if (currentX > threshold) track.dispatchEvent(new CustomEvent('carousel-prev'));
track.style.transform = '';
currentX = 0;
});
React + Framer Motion approach {Cách React + Framer Motion}
The drag prop with dragConstraints and onDragEnd is the idiomatic carousel/swipe pattern {Prop drag với dragConstraints và onDragEnd là pattern carousel/swipe đúng chuẩn}:
import { motion, type PanInfo } from 'framer-motion';
const SWIPE_THRESHOLD = 80;
export function SwipeCard({
children,
onSwipeLeft,
onSwipeRight,
}: {
children: React.ReactNode;
onSwipeLeft: () => void;
onSwipeRight: () => void;
}) {
function handleDragEnd(_: unknown, info: PanInfo) {
if (info.offset.x < -SWIPE_THRESHOLD) onSwipeLeft();
else if (info.offset.x > SWIPE_THRESHOLD) onSwipeRight();
}
return (
<motion.div
className="swipe-card"
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.12}
onDragEnd={handleDragEnd}
whileTap={{ scale: 0.98 }}
>
{children}
</motion.div>
);
}
Use dragMomentum={false} for precise snapping and whileDrag for lift feedback {Dùng dragMomentum={false} để snap chính xác và whileDrag cho phản hồi nâng}.
Library vs plain CSS {Thư viện vs CSS thuần}
Plain pointer JS is fine for one carousel {Pointer JS thuần ổn cho một carousel}. Framer Motion drag adds elastic constraints, momentum, and accessibility-friendly keyboard fallbacks with far less code {drag Framer Motion thêm ràng buộc elastic, momentum, và fallback bàn phím thân thiện a11y với ít code hơn nhiều}.
Page and route transitions {Chuyển trang và route}
What it is {Là gì}: animated exits and entrances when navigating between landing sub-pages or modal routes {exit và entrance animate khi điều hướng giữa sub-page landing hoặc route modal}. When to use it {Khi nào dùng}: SPA-style marketing sites, product wizards, and overlay pricing flows — not on every link in a content-heavy blog {site marketing kiểu SPA, wizard sản phẩm, flow giá overlay — không phải mọi link trên blog nhiều content}.
See it live {Xem trực tiếp}: demo: page transition.
CSS approach {Cách CSS}
The View Transitions API handles cross-document and same-document morphs with zero React {View Transitions API xử lý morph cross-document và same-document không cần React}:
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}
::view-transition-old(root) {
animation: fade-out 200ms ease both;
}
::view-transition-new(root) {
animation: fade-in 200ms ease both;
}
document.querySelectorAll('a[data-transition]').forEach((link) => {
link.addEventListener('click', (event) => {
if (!document.startViewTransition) return;
event.preventDefault();
const href = link.getAttribute('href');
if (!href) return;
document.startViewTransition(async () => {
await fetch(href);
window.location.href = href;
});
});
});
@starting-style enables entry animation on newly inserted DOM without a framework {@starting-style bật entry animation trên DOM mới chèn mà không cần framework}:
.modal-panel {
transition: opacity 300ms ease, transform 300ms ease;
}
.modal-panel {
@starting-style {
opacity: 0;
transform: translateY(16px);
}
}
Vanilla JS approach {Cách vanilla JS}
Toggle classes on route change events from your router (or astro:transitions) and listen for transitionend {Toggle class trên sự kiện đổi route từ router (hoặc astro:transitions) và nghe transitionend}:
document.addEventListener('astro:before-preparation', () => {
document.documentElement.classList.add('is-leaving');
});
document.addEventListener('astro:after-swap', () => {
document.documentElement.classList.remove('is-leaving');
document.documentElement.classList.add('is-entering');
requestAnimationFrame(() => {
document.documentElement.classList.remove('is-entering');
});
});
html.is-leaving main {
opacity: 0;
transform: translateY(8px);
transition: opacity 200ms ease, transform 200ms ease;
}
html.is-entering main {
opacity: 0;
transform: translateY(8px);
}
main {
transition: opacity 300ms ease, transform 300ms ease;
}
React + Framer Motion approach {Cách React + Framer Motion}
AnimatePresence with mode="wait" orchestrates exit-before-enter in SPAs {AnimatePresence với mode="wait" điều phối exit-trước-enter trong SPA}:
import { AnimatePresence, motion } from 'framer-motion';
import { useLocation, Routes, Route } from 'react-router-dom';
const pageVariants = {
initial: { opacity: 0, y: 12 },
animate: { opacity: 1, y: 0, transition: { duration: 0.35, ease: [0.22, 1, 0.36, 1] } },
exit: { opacity: 0, y: -8, transition: { duration: 0.25 } },
};
export function AnimatedRoutes() {
const location = useLocation();
return (
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Route
path="/"
element={
<motion.main variants={pageVariants} initial="initial" animate="animate" exit="exit">
<HomePage />
</motion.main>
}
/>
<Route
path="/pricing"
element={
<motion.main variants={pageVariants} initial="initial" animate="animate" exit="exit">
<PricingPage />
</motion.main>
}
/>
</Routes>
</AnimatePresence>
);
}
Combine with View Transitions for shared-element morphs: call document.startViewTransition inside flushSync when swapping route content {Kết hợp View Transitions cho morph shared-element: gọi document.startViewTransition trong flushSync khi đổi content route}.
Library vs plain CSS {Thư viện vs CSS thuần}
View Transitions API is the 2026 baseline for MPA and Astro view transitions {View Transitions API là baseline 2026 cho MPA và Astro view transitions}. AnimatePresence remains essential for React SPAs with complex staged exits or shared layout animations {AnimatePresence vẫn thiết yếu cho React SPA với exit staged phức tạp hoặc layout animation chung}.
Reduced motion and performance guardrails {Reduced motion và rào hiệu năng}
What it is {Là gì}: detecting prefers-reduced-motion, disabling non-essential animation, and keeping motion on the compositor thread {phát hiện prefers-reduced-motion, tắt animation không thiết yếu, và giữ motion trên compositor thread}. When to use it {Khi nào dùng}: always — this is not optional polish, it is a shipping requirement {luôn luôn — đây không phải polish tuỳ chọn, mà là yêu cầu khi ship}.
See it live {Xem trực tiếp}: demo: reduced motion.
CSS approach {Cách CSS}
Global fallback that preserves instant state changes but kills decorative loops {Fallback toàn cục giữ đổi state tức thì nhưng dừng loop trang trí}:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
Prefer targeted overrides over the nuclear selector when you need to keep essential feedback {Ưu tiên override có mục tiêu thay vì selector “hạt nhân” khi cần giữ phản hồi thiết yếu}:
@media (prefers-reduced-motion: reduce) {
.parallax-hero .hero-bg,
.parallax-hero .hero-fg {
animation: none;
transform: none;
}
}
Vanilla JS approach {Cách vanilla JS}
Respect the media query at init time and listen for changes {Tôn trọng media query lúc init và lắng nghe thay đổi}:
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
function applyMotionPreference(event) {
document.documentElement.toggleAttribute('data-reduce-motion', event.matches);
}
applyMotionPreference(motionQuery);
motionQuery.addEventListener('change', applyMotionPreference);
Skip initializing IntersectionObserver-driven reveals when reduced motion is on {Bỏ qua init reveal IntersectionObserver khi bật reduced motion}:
if (!motionQuery.matches) {
initScrollReveals();
}
React + Framer Motion approach {Cách React + Framer Motion}
useReducedMotion() returns true when the user prefers reduced motion — branch variants and transitions {useReducedMotion() trả về true khi người dùng muốn ít chuyển động — rẽ nhánh variant và transition}:
import { motion, useReducedMotion } from 'framer-motion';
export function SafeReveal({ children }: { children: React.ReactNode }) {
const reduceMotion = useReducedMotion();
return (
<motion.div
initial={reduceMotion ? false : { opacity: 0, y: 24 }}
whileInView={reduceMotion ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: reduceMotion ? 0 : 0.5 }}
>
{children}
</motion.div>
);
}
Setting initial={false} skips the entrance entirely — preferred over animating with duration: 0 {Đặt initial={false} bỏ hẳn entrance — ưu tiên hơn animate với duration: 0}. Lazy-mount below-fold motion components with IntersectionObserver or whileInView so work starts only when needed {Lazy-mount component motion dưới fold với IntersectionObserver hoặc whileInView để chỉ chạy khi cần}.
Library vs plain CSS {Thư viện vs CSS thuần}
CSS @media (prefers-reduced-motion) is the floor — every stack must have it {CSS @media (prefers-reduced-motion) là sàn — mọi stack phải có}. useReducedMotion() adds runtime granularity inside React trees where CSS alone cannot know component intent {useReducedMotion() thêm độ chi tiết runtime trong cây React nơi CSS không biết intent component}.
Choosing a library {Chọn thư viện}
No single library wins every landing page {Không thư viện nào thắng mọi landing page}. Match the tool to your framework, bundle budget, and the hardest animation on the page {Khớp công cụ với framework, ngân sách bundle, và animation khó nhất trên trang}.
| Library | Bundle / cost | Strengths | Trade-offs |
|---|---|---|---|
| Framer Motion (React) | ~30–40 kB gzip | Declarative API, whileInView, gestures, layout, AnimatePresence | React-only; overkill for static SSG pages |
| Motion / Motion One (motion.dev) | ~3–5 kB gzip | Framework-agnostic, WAAPI-based, tiny | No React-specific layout magic; gestures less ergonomic than Framer |
| GSAP + ScrollTrigger | ~25 kB+ gzip | Most powerful timelines, scroll pinning, morph | Imperative API; license for some plugins; main-thread |
| Lenis | ~5 kB gzip | Buttery smooth scroll; pairs with scroll animations | Does not animate — only normalizes scroll input |
| AutoAnimate | ~2 kB gzip | Zero-config list insert/remove/move | Limited to layout shifts; no hero choreography |
| Native CSS | 0 kB | view(), scroll(), View Transitions, @starting-style | Baseline support gaps; no drag/gesture |
Pick X if… {Chọn X nếu…}
- Framer Motion — you ship React/Next and need gestures, exit animations, or shared layout {bạn ship React/Next và cần gestures, exit animation, hoặc shared layout}.
- Motion One — you want WAAPI power in vanilla JS or Vue/Svelte without React overhead {bạn muốn sức mạnh WAAPI trong vanilla JS hoặc Vue/Svelte không overhead React}.
- GSAP + ScrollTrigger — the landing page is a scroll-driven story with pinned sections and complex timelines {landing page là câu chuyện scroll-driven với section pin và timeline phức tạp}.
- Lenis — scroll feels jerky on macOS trackpads and you already have scroll-linked animations that assume smooth input {scroll giật trên trackpad macOS và bạn đã có animation gắn scroll giả định input mượt}.
- AutoAnimate — you only need FAQ accordions and feature lists to reorder without writing variants {bạn chỉ cần FAQ accordion và list tính năng reorder mà không viết variant}.
- Native CSS — the site is static SSG (Astro), effects are reveal/progress/parallax, and your browser baseline supports scroll-driven animations {site SSG tĩnh (Astro), hiệu ứng là reveal/progress/parallax, và baseline trình duyệt hỗ trợ scroll-driven animation}.
Honest rule {Quy tắc thật thà}: start with CSS + one small JS observer layer {bắt đầu với CSS + một lớp observer JS nhỏ}. Add a library only when a concrete effect (drag, layout morph, timeline pin) exceeds ~50 lines of imperative code {chỉ thêm thư viện khi hiệu ứng cụ thể (drag, morph layout, pin timeline) vượt ~50 dòng code mệnh lệnh}.
Performance and accessibility {Hiệu năng & khả năng tiếp cận}
Compositor-only properties {Property chỉ compositor}: stick to transform and opacity for continuous animation {bám transform và opacity cho animation liên tục}. Promoting layers with will-change: transform is fine on elements that will animate within the next 200 ms — remove it after the animation completes to avoid exhausting GPU memory {Promote layer bằng will-change: transform ổn trên phần tử sẽ animate trong 200 ms tới — gỡ sau khi animation xong để tránh cạn bộ nhớ GPU}.
Avoid layout thrash {Tránh layout thrash}: never read offsetHeight and write width in the same frame loop {không bao giờ đọc offsetHeight và ghi width trong cùng vòng frame}. Batch DOM reads, then writes, in scroll handlers — or eliminate the handler with CSS scroll timelines {Gom đọc DOM, rồi ghi, trong scroll handler — hoặc loại handler bằng CSS scroll timeline}.
Do not block interaction {Không chặn tương tác}: entrance orchestration must not set pointer-events: none on the hero longer than 300 ms {điều phối entrance không được đặt pointer-events: none trên hero quá 300 ms}. CTA buttons should be focusable immediately even if visually still animating {Nút CTA phải focus được ngay cả khi vẫn đang animate hình ảnh}.
prefers-reduced-motion {`prefers-reduced-motion`}: implement at the CSS layer first, then mirror with useReducedMotion() in React islands {triển khai lớp CSS trước, rồi mirror bằng useReducedMotion() trong React island}. Never rely on users finding a “disable animations” toggle in settings {Không bao giờ trông chờ người dùng tìm toggle “tắt animation” trong settings}.
Lazy-init below the fold {Lazy-init dưới fold}: defer IntersectionObserver registration and heavy motion components until the section nears the viewport {hoãn đăng ký IntersectionObserver và component motion nặng tới khi section gần viewport}. Static Astro pages should ship zero motion JS until the user scrolls {Trang Astro tĩnh nên ship zero motion JS cho tới khi user scroll}.
Measure {Đo lường}: profile with Performance panel — if animation frames exceed 16 ms consistently, reduce simultaneous movers or switch to CSS scroll-driven timelines {profile bằng Performance panel — nếu frame animation vượt 16 ms liên tục, giảm phần tử chuyển động đồng thời hoặc chuyển sang CSS scroll-driven timeline}.
Decision checklist {Danh sách quyết định}
Before shipping landing-page motion, walk this list {Trước khi ship motion landing page, đi qua danh sách này}:
- Does every animation answer a UX question (hierarchy, feedback, progress) — not just look cool? {Mỗi animation có trả lời câu hỏi UX (thứ bậc, phản hồi, tiến độ) — không chỉ trông ngầu?}
- Are you animating only
transformandopacityon the hot path? {Bạn chỉ animatetransformvàopacitytrên hot path?} - Is
@media (prefers-reduced-motion: reduce)wired globally and mirrored in React viauseReducedMotion()? {\@media (prefers-reduced-motion: reduce)đã nối toàn cục và mirror trong React quauseReducedMotion()?} - Can the primary CTA receive focus within 300 ms of load? {CTA chính focus được trong 300 ms sau load?}
- Are below-the-fold effects lazy-init (observer /
whileInView) rather than running at parse time? {Hiệu ứng dưới fold lazy-init (observer /whileInView) thay vì chạy lúc parse?} - Did you try CSS scroll-driven
view()/scroll()before adding a scroll listener library? {Bạn đã thử CSS scroll-drivenview()/scroll()trước khi thêm thư viện scroll listener?} - If using Framer Motion, is the bundle justified by gestures, exits, or layout — not a fade you could do in CSS? {Nếu dùng Framer Motion, bundle có xứng vì gestures, exit, hoặc layout — không phải fade làm được bằng CSS?}
- Did you profile one throttled-CPU session and confirm no layout thrash in scroll handlers? {Bạn đã profile một phiên CPU throttle và xác nhận không layout thrash trong scroll handler?}
- Does the demo or staging build pass an axe / Lighthouse accessibility scan with motion enabled? {Demo hoặc staging pass scan axe / Lighthouse a11y với motion bật?}
- Is
will-changeapplied sparingly and removed after animation completes? {will-changedùng tiết kiệm và gỡ sau khi animation xong?}
Further reading {Đọc thêm}
- CSS @keyframes & the animation Shorthand — fill-mode, stagger delays, animation events
- CSS Scroll-Driven Animations —
view(),scroll(), compositor-native scroll UX - CSS
@starting-styleEntry & Exit Animations — entry without JavaScript - View Transitions API Guide — cross-document morphs and
:view-transition - CSS
prefers-reduced-motion— Accessible Animation — global fallback patterns