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 nên dẫn sự chú ý, không phải trang trí

Animation trên landing page chỉ xứng đáng khi nó trả lời một câu hỏi UX: mắt nên đi đâu trước, cái gì đổi sau một hành động, khách đã đi được bao xa trong câu chuyện. 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.

Bài này map mười hiệu ứng landing page qua ba lớp triển khai: CSS hiện đại (scroll-driven timeline, @starting-style, View Transitions), vanilla JS (IntersectionObserver, WAAPI, pointer events), React + Framer Motion (variants khai báo, gestures, layout). 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.

Quy tắc mặc định xuyên suốt: chỉ animate transformopacity, tôn trọng prefers-reduced-motion, và lazy-init mọi thứ dưới fold.

Mở demo đầy đủ:


Điều phối khi load trang

Là gì: 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. Khi nào dùng: 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. 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.

Xem trực tiếp:

Cách CSS

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;
  }
}

Cách vanilla JS

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();

Kết hợp CSS chỉ chạy animation khi có .is-ready. Để đ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');
});

Cách React + Framer Motion

Đị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>
  );
}

Thư viện vs CSS thuần

Stagger CSS thuần qua animation-delay không cần JS và thân compositor. 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.


Hiện khi scroll

Là gì: phần tử fade hoặc trượt vào khi lọt viewport. Khi nào dùng: 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.

Xem trực tiếp:

Cách CSS

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;
  }
}

Cách vanilla JS

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);
}

Cách React + Framer Motion

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>
  );
}

Thư viện vs CSS thuần

Ưu tiên animation-timeline: view() khi baseline trình duyệt khớp audience. Dùng Framer Motion khi reveal phụ thuộc list key React, render có điều kiện, hoặc layout context chung.


Stagger các phần tử con

Là gì: container cha kích hoạt các con theo thứ tự với offset cố định hoặc động. Khi nào dùng: 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.

Xem trực tiếp:

Cách CSS

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; }

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%; }

Cách vanilla JS

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);
}

Cách React + Framer Motion

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>
  );
}

Dùng staggerDirection: -1 cho thứ tự ngược và staggerChildren dạng function cho list động.

Thư viện vs CSS thuần

Stagger :nth-child CSS lý tưởng cho markup tĩnh. Framer Motion thắng khi item mount/unmount (list lọc) hoặc stagger phải sync với exit AnimatePresence.


Reveal text hero

Là gì: copy headline animate từng từ, từng dòng, hoặc qua clip/mask wipe. Khi nào dùng: headline hero ≤12 từ — đủ drama đặt tone, không đến mức ảnh hưởng LCP. Luôn giữ full text trong DOM cho SEO và screen reader.

Xem trực tiếp:

Cách CSS

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; }

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;
}

Cách vanilla JS

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);
}

Cách React + Framer Motion

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>
  );
}

Thư viện vs CSS thuần

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 đơn giản hoá headline động (chuỗi i18n, copy A/B) mà không cần tính delay thủ công.


Parallax khi scroll

Là gì: lớp foreground và background di chuyển với tốc độ khác nhau khi scroll, tạo chiều sâu. Khi nào dùng: minh hoạ hero, blob trang trí, divider section — không bao giờ trên body text hay control tương tác.

Xem trực tiếp:

Cách CSS

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);
}

Cách vanilla JS

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 }
);

Cách React + Framer Motion

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>
  );
}

Thư viện vs CSS thuần

Parallax scroll-driven CSS native compositor và là lựa chọn đầu tiên năm 2026. useScroll Framer Motion mạnh khi parallax gắn scroll container React (modal, carousel) thay vì document.


Tiến độ scroll và sticky pin

Là gì: 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. Khi nào dùng: landing page dài, storytelling sản phẩm, trang marketing kiểu docs.

Xem trực tiếp:

Cách CSS

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 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%;
}

Cách vanilla JS

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;
}

Cách React + Framer Motion

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"
    />
  );
}

Với section sticky có con animate, bọc block pin trong motion.div và drive opacity từ segment scrollYProgress cùng đó.

Thư viện vs CSS thuần

Timeline scroll() CSS là default đúng cho progress bar. Thêm Framer Motion khi indicator đổi hình (thanh → vòng) qua range useTransform hoặc spring.


Micro-interaction hover

Là gì: nút magnetic bám con trỏ, thẻ tilt 3D, và scale/glow nhẹ khi hover. Khi nào dùng: CTA chính, thẻ giá, tile portfolio — một hoặc hai mỗi viewport, không phải mọi link.

Xem trực tiếp:

Cách CSS

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);
}

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ỏ.

Cách vanilla JS

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;
}

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)';
});

Cách React + Framer Motion

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>
  );
}

Thư viện vs CSS thuần

Transition :hover CSS cover 80% micro-interaction landing. 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.


Cử chỉ

Là gì: phản hồi drag, press và tap trên slider, carousel, thẻ swipe, panel dismiss. Khi nào dùng: section landing mobile-first khi touch là input chính.

Xem trực tiếp:

Cách CSS

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 không track offset drag native — cần JS hoặc thư viện.

Cách vanilla JS

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;
});

Cách React + Framer Motion

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>
  );
}

Dùng dragMomentum={false} để snap chính xác và whileDrag cho phản hồi nâng.

Thư viện vs CSS thuần

Pointer JS thuần ổn cho một carousel. 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.


Chuyển trang và route

Là gì: exit và entrance animate khi điều hướng giữa sub-page landing hoặc route modal. Khi nào dùng: 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.

Xem trực tiếp:

Cách CSS

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 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);
  }
}

Cách vanilla JS

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;
}

Cách React + Framer Motion

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>
  );
}

Kết hợp View Transitions cho morph shared-element: gọi document.startViewTransition trong flushSync khi đổi content route.

Thư viện vs CSS thuần

View Transitions API là baseline 2026 cho MPA và Astro view transitions. 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 và rào hiệu năng

Là gì: phát hiện prefers-reduced-motion, tắt animation không thiết yếu, và giữ motion trên compositor thread. Khi nào dùng: luôn luôn — đây không phải polish tuỳ chọn, mà là yêu cầu khi ship.

Xem trực tiếp:

Cách CSS

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;
  }
}

Ư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;
  }
}

Cách vanilla JS

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);

Bỏ qua init reveal IntersectionObserver khi bật reduced motion:

if (!motionQuery.matches) {
  initScrollReveals();
}

Cách React + Framer Motion

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>
  );
}

Đặt initial={false} bỏ hẳn entrance — ưu tiên hơn animate với duration: 0. Lazy-mount component motion dưới fold với IntersectionObserver hoặc whileInView để chỉ chạy khi cần.

Thư viện vs CSS thuần

CSS @media (prefers-reduced-motion) là sàn — mọi stack phải có. useReducedMotion() thêm độ chi tiết runtime trong cây React nơi CSS không biết intent component.


Chọn thư viện

Không thư viện nào thắng mọi landing 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

Chọn X nếu…

  • bạn ship React/Next và cần gestures, exit animation, hoặc shared layout.
  • bạn muốn sức mạnh WAAPI trong vanilla JS hoặc Vue/Svelte không overhead React.
  • landing page là câu chuyện scroll-driven với section pin và timeline phức tạp.
  • scroll giật trên trackpad macOS và bạn đã có animation gắn scroll giả định input mượt.
  • bạn chỉ cần FAQ accordion và list tính năng reorder mà không viết variant.
  • site SSG tĩnh (Astro), hiệu ứng là reveal/progress/parallax, và baseline trình duyệt hỗ trợ scroll-driven animation.

Quy tắc thật thà: bắt đầu với CSS + một lớp observer JS nhỏ. 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.


Hiệu năng & khả năng tiếp cận

Property chỉ compositor: bám transformopacity cho animation liên tục. 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.

Tránh layout thrash: không bao giờ đọc offsetHeight và ghi width trong cùng vòng frame. Gom đọc DOM, rồi ghi, trong scroll handler — hoặc loại handler bằng CSS scroll timeline.

Không chặn tương tác: điều phối entrance không được đặt pointer-events: none trên hero quá 300 ms. Nút CTA phải focus được ngay cả khi vẫn đang animate hình ảnh.

`prefers-reduced-motion`: triển khai lớp CSS trước, rồi mirror bằng useReducedMotion() trong React island. Không bao giờ trông chờ người dùng tìm toggle “tắt animation” trong settings.

Lazy-init dưới fold: hoãn đăng ký IntersectionObserver và component motion nặng tới khi section gần viewport. Trang Astro tĩnh nên ship zero motion JS cho tới khi user scroll.

Đo lường: 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.


Danh sách quyết định

Trước khi ship motion landing page, đi qua danh sách này:

  • 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?
  • Bạn chỉ animate transformopacity trên hot path?
  • \@media (prefers-reduced-motion: reduce) đã nối toàn cục mirror trong React qua useReducedMotion()?
  • CTA chính focus được trong 300 ms sau load?
  • Hiệu ứng dưới fold lazy-init (observer / whileInView) thay vì chạy lúc parse?
  • Bạn đã thử CSS scroll-driven view() / scroll() trước khi thêm thư viện scroll listener?
  • 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?
  • Bạn đã profile một phiên CPU throttle và xác nhận không layout thrash trong scroll handler?
  • Demo hoặc staging pass scan axe / Lighthouse a11y với motion bật?
  • will-change dùng tiết kiệm và gỡ sau khi animation xong?

Đọc thêm