jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 transformopacity}, 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 hiddenvisible {Định nghĩa variant container cha với staggerChildren và để mỗi con kế thừa hiddenvisible}:

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 {whileHoverwhileTap 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 dragConstraintsonDragEnd 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}.

LibraryBundle / costStrengthsTrade-offs
Framer Motion (React)~30–40 kB gzipDeclarative API, whileInView, gestures, layout, AnimatePresenceReact-only; overkill for static SSG pages
Motion / Motion One (motion.dev)~3–5 kB gzipFramework-agnostic, WAAPI-based, tinyNo React-specific layout magic; gestures less ergonomic than Framer
GSAP + ScrollTrigger~25 kB+ gzipMost powerful timelines, scroll pinning, morphImperative API; license for some plugins; main-thread
Lenis~5 kB gzipButtery smooth scroll; pairs with scroll animationsDoes not animate — only normalizes scroll input
AutoAnimate~2 kB gzipZero-config list insert/remove/moveLimited to layout shifts; no hero choreography
Native CSS0 kBview(), scroll(), View Transitions, @starting-styleBaseline 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 transformopacity 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 transform and opacity on the hot path? {Bạn chỉ animate transformopacity trên hot path?}
  • Is @media (prefers-reduced-motion: reduce) wired globally and mirrored in React via useReducedMotion()? {\@media (prefers-reduced-motion: reduce) đã nối toàn cục mirror trong React qua useReducedMotion()?}
  • 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-driven view() / 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-change applied sparingly and removed after animation completes? {will-change dùng tiết kiệm và gỡ sau khi animation xong?}

Further reading {Đọc thêm}