Hướng dẫn Thực hành

Màu Sắc Trong Hiệu Ứng Web: GSAP, Framer Motion và CSS

Đọc 11 phút

Color is the fastest signal in motion design. Before a user reads a label or follows a movement path, they have already registered the color change. A button that warms from slate to amber on hover, a status ring that pulses between #10B981 and #059669 during processing, a page transition that sweeps a brand hue across the viewport — these are not decorations. They communicate state, urgency, brand identity, and structure.

But implementing color animation well across the CSS, GSAP, and Framer Motion ecosystems requires understanding how each tool interpolates color, where performance bottlenecks live, and how modern color spaces like OKLCH can make the difference between muddy midpoints and smooth, professional transitions.

This guide covers the full stack of web color animation, from plain CSS to advanced JavaScript animation libraries, with practical code patterns you can use immediately.


CSS Color Transitions: The Foundation

CSS transitions are the lowest-overhead path for color animation. They require no JavaScript, add no bundle weight, and are GPU-accelerated on modern browsers for most use cases.

Core Syntax

.button {
  background-color: #3B82F6;
  transition: background-color 200ms ease-out;
}

.button:hover {
  background-color: #1D4ED8;
}

This transitions from #3B82F6 (blue-500) to #1D4ED8 (blue-700) on hover. The ease-out timing function — fast start, slow finish — matches physical color perception better than linear, making the transition feel natural and resolved.

Which Properties Animate

Every CSS property that accepts a color value is animatable: background-color, color, border-color, outline-color, box-shadow (the color component), text-decoration-color, caret-color, fill, stroke, and stop-color (SVG). Custom properties registered with @property and typed as <color> are also animatable.

.card {
  background-color: #F8FAFC;
  border-color: #E2E8F0;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.1);
  transition:
    background-color 300ms ease-out,
    border-color 300ms ease-out,
    box-shadow 300ms ease-out;
}

.card:hover {
  background-color: #EFF6FF;
  border-color: #93C5FD;
  box-shadow: 0 4px 12px rgb(59 130 246 / 0.15);
}

The @property Rule for Custom Color Interpolation

CSS cannot natively interpolate between custom property values unless the browser knows their type. The @property at-rule registers a custom property with explicit syntax, enabling smooth animation:

@property --bg-color {
  syntax: "<color>";
  initial-value: #3B82F6;
  inherits: false;
}

.animated {
  background-color: var(--bg-color);
  transition: --bg-color 400ms ease;
}

.animated:hover {
  --bg-color: #7C3AED;
}

With syntax: "<color>", the browser knows to interpolate the hex values frame by frame rather than snapping. This enables gradient animation — something not possible with standard CSS:

@property --color-start {
  syntax: "<color>";
  initial-value: #3B82F6;
  inherits: false;
}

@property --color-end {
  syntax: "<color>";
  initial-value: #8B5CF6;
  inherits: false;
}

.gradient-button {
  background: linear-gradient(135deg, var(--color-start), var(--color-end));
  transition: --color-start 500ms ease, --color-end 500ms ease;
}

.gradient-button:hover {
  --color-start: #EF4444;
  --color-end: #F97316;
}

Keyframe Color Animation in CSS

The @keyframes rule enables multi-step color sequences that run continuously, fire on load, or trigger programmatically via class toggling. Unlike transitions, keyframes do not require a state change event.

Looping Status Indicators

@keyframes status-pulse {
  0%, 100% { background-color: #10B981; box-shadow: 0 0 0 0 rgb(16 185 129 / 0.4); }
  50%       { background-color: #34D399; box-shadow: 0 0 0 8px rgb(16 185 129 / 0); }
}

.status-dot {
  animation: status-pulse 2s ease-in-out infinite;
}

This creates a living, breathing status indicator that pulses from #10B981 to #34D399 while simultaneously expanding and fading a glow shadow — a common pattern in dashboards and live-data interfaces.

HSL Hue Rotation with @property

Animating the hue channel in HSL lets you cycle the full color spectrum without enumerating individual stops. The key is registering the hue variable as a <number> type so the browser interpolates it numerically:

@property --hue {
  syntax: "<number>";
  initial-value: 220;
  inherits: false;
}

@keyframes hue-cycle {
  from { --hue: 0; }
  to   { --hue: 360; }
}

.rainbow-accent {
  background-color: hsl(var(--hue) 75% 55%);
  animation: hue-cycle 6s linear infinite;
}

For branded animations that should not stray into arbitrary hues, constrain the range:

@keyframes brand-shift {
  0%   { --hue: 200; }   /* Cyan */
  50%  { --hue: 260; }   /* Purple */
  100% { --hue: 200; }
}

GSAP Color Animation

GSAP (GreenSock Animation Platform) is the industry standard for complex JavaScript-driven animation. Its color handling is automatic and comprehensive — pass any hex, RGB, HSL, or named color string and GSAP interpolates frame by frame.

Basic GSAP Color Tweens

import { gsap } from "gsap";

// Animate background color of an element
gsap.to(".button", {
  backgroundColor: "#7C3AED",
  duration: 0.4,
  ease: "power2.out"
});

// Animate text color with delay
gsap.to(".heading", {
  color: "#F97316",
  duration: 0.6,
  delay: 0.2,
  ease: "expo.out"
});

GSAP automatically parses the current computed color of the element and interpolates to the target. You can use any valid CSS color string as the target value.

GSAP Timelines for Choreographed Color Sequences

Timelines let you sequence multiple color changes with precise timing relationships:

const tl = gsap.timeline({ paused: true });

tl.to(".hero-bg", { backgroundColor: "#1E3A8A", duration: 0.8 })
  .to(".hero-text", { color: "#BFDBFE", duration: 0.4 }, "-=0.4")
  .to(".cta-button", { backgroundColor: "#F59E0B", duration: 0.3 }, "-=0.2");

// Trigger on scroll or event
document.querySelector(".trigger").addEventListener("mouseenter", () => tl.play());
document.querySelector(".trigger").addEventListener("mouseleave", () => tl.reverse());

The position parameter ("-=0.4") overlaps the second animation with the last 0.4 seconds of the first, creating the layered feel of professional motion design rather than sequential mechanical steps.

GSAP Color Plugin (ColorProps)

The GSAP ColorProps plugin allows you to animate CSS custom properties that contain color values:

import { gsap } from "gsap";

// Animate a CSS custom property color value
gsap.to(document.documentElement, {
  "--brand-primary": "#EF4444",
  duration: 1,
  ease: "sine.inOut"
});

This is powerful for theme-level color animations — animating a single CSS custom property cascades the change through every element that references it.

GSAP with SVG Colors

GSAP handles SVG fill and stroke attributes natively:

gsap.to(".logo-path", {
  fill: "#F97316",
  stroke: "#EA580C",
  strokeWidth: 2,
  duration: 0.5,
  ease: "power1.inOut"
});

For complex SVG icons with multiple paths requiring different color targets, use gsap.to() with a selector matching multiple elements, or build a timeline with individual targets.

GSAP ScrollTrigger for Scroll-Linked Color

A common production pattern pairs color animation with scroll progress:

import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

gsap.to(".site-header", {
  backgroundColor: "#0F172A",
  scrollTrigger: {
    trigger: ".hero",
    start: "top top",
    end: "bottom top",
    scrub: true   // Ties animation progress directly to scroll position
  }
});

The scrub: true option links the tween progress to the scroll position — as the user scrolls down, the header background color interpolates from its initial value toward #0F172A. This creates the sticky-header darkening effect common on modern marketing sites.


Framer Motion Color Animation

Framer Motion is the dominant animation library in the React ecosystem. Its animate prop, useMotionValue, and useTransform hooks provide a declarative interface for color animation.

Basic Color Animation with animate

import { motion } from "framer-motion";

function ColorButton() {
  return (
    <motion.button
      initial={{ backgroundColor: "#3B82F6" }}
      whileHover={{ backgroundColor: "#1D4ED8" }}
      whileTap={{ backgroundColor: "#1E40AF" }}
      transition={{ duration: 0.2, ease: "easeOut" }}
    >
      Click me
    </motion.button>
  );
}

Framer Motion interpolates between hex values automatically. The whileHover and whileTap variants snap back to initial values when the event ends.

Animated Color with variants

Variants enable reusable, named animation states across complex component trees:

const cardVariants = {
  rest: { backgroundColor: "#F8FAFC", borderColor: "#E2E8F0" },
  hover: { backgroundColor: "#EFF6FF", borderColor: "#93C5FD" },
  selected: { backgroundColor: "#DBEAFE", borderColor: "#3B82F6" }
};

function SelectableCard({ isSelected }) {
  return (
    <motion.div
      variants={cardVariants}
      animate={isSelected ? "selected" : "rest"}
      whileHover={!isSelected ? "hover" : undefined}
      transition={{ duration: 0.25 }}
    >
      {/* content */}
    </motion.div>
  );
}

useMotionValue and useTransform for Pointer-Linked Color

useMotionValue creates a reactive value that does not trigger re-renders, making it ideal for high-frequency events like mouse position:

import { motion, useMotionValue, useTransform } from "framer-motion";

function MagneticCard() {
  const x = useMotionValue(0);
  const y = useMotionValue(0);

  // Map x position (-150 to 150px) to hue (200 to 260)
  const hue = useTransform(x, [-150, 150], [200, 260]);
  const backgroundColor = useTransform(
    hue,
    (h) => `hsl(${h}deg 70% 55%)`
  );

  return (
    <motion.div
      style={{ backgroundColor, x, y }}
      onMouseMove={(e) => {
        const rect = e.currentTarget.getBoundingClientRect();
        x.set(e.clientX - rect.left - rect.width / 2);
        y.set(e.clientY - rect.top - rect.height / 2);
      }}
      onMouseLeave={() => {
        x.set(0);
        y.set(0);
      }}
    />
  );
}

The useTransform hook maps the motion value range to a color string, creating a smooth hue shift that follows the cursor. This runs entirely outside React's render cycle — no state updates, no re-renders.

Framer Motion keyframes for Multi-Stop Color Animation

<motion.div
  animate={{
    backgroundColor: ["#1E3A8A", "#7C3AED", "#DB2777", "#1E3A8A"]
  }}
  transition={{
    duration: 8,
    repeat: Infinity,
    ease: "linear"
  }}
/>

Passing an array as the backgroundColor value creates a keyframe sequence. Framer Motion steps through the array at equal time intervals. Add a times array to control the distribution:

transition={{
  duration: 8,
  times: [0, 0.3, 0.7, 1],   // Spend more time near the first color
  repeat: Infinity,
  ease: "easeInOut"
}}

OKLCH Interpolation for Smooth Color Animation

The single biggest upgrade available to color animation in 2024–2025 is switching interpolation from sRGB to OKLCH. Both CSS and JavaScript libraries support it, and the visual difference is dramatic for certain color pairs.

The Problem with sRGB Interpolation

When a browser interpolates between two colors in sRGB, it linearly blends the R, G, and B channel values. This is numerically simple but perceptually non-uniform. Consider a transition from a vivid orange #F97316 to a vivid purple #7C3AED: the midpoint in sRGB space is a desaturated brownish gray. That muddy middle is a byproduct of the color model, not the design intent.

OKLCH (OK Lightness-Chroma-Hue) is designed so equal numeric steps produce equal perceived steps. The midpoint between an orange and a purple in OKLCH space is a vivid, saturated red — because lightness and chroma stay constant while only hue changes.

OKLCH in CSS Gradients

Use the in oklch keyword to specify the interpolation color space for gradients:

/* sRGB interpolation — muddy gray midpoint */
background: linear-gradient(to right, #F97316, #7C3AED);

/* OKLCH interpolation — vivid red midpoint */
background: linear-gradient(in oklch to right,
  oklch(65% 0.22 47deg),
  oklch(50% 0.22 295deg)
);

To find the OKLCH equivalents of your hex brand colors, use ColorFYI's Color Converter to convert between formats instantly.

You can also control which direction around the hue wheel the gradient travels:

/* Shorter arc (default) */
background: linear-gradient(in oklch to right,
  oklch(60% 0.2 60deg),   /* Yellow */
  oklch(60% 0.2 300deg)   /* Magenta */
);

/* Longer arc — passes through blue-green */
background: linear-gradient(in oklch longer hue to right,
  oklch(60% 0.2 60deg),
  oklch(60% 0.2 300deg)
);

Build your base gradient with ColorFYI's Gradient Generator, then add in oklch to the gradient declaration for perceptually uniform interpolation.

OKLCH Animation with CSS @keyframes

@property --oklch-hue {
  syntax: "<number>";
  initial-value: 200;
  inherits: false;
}

@keyframes oklch-hue-cycle {
  from { --oklch-hue: 0; }
  to   { --oklch-hue: 360; }
}

.smooth-spectrum {
  /* Constant lightness (60%) and chroma (0.2), only hue varies */
  background-color: oklch(60% 0.2 var(--oklch-hue));
  animation: oklch-hue-cycle 8s linear infinite;
}

Because OKLCH lightness and chroma are held constant, this animation maintains consistent perceived brightness and saturation throughout the entire hue rotation. An equivalent HSL hue rotation would have noticeably darker and lighter patches as it passes through yellow and violet regions.

OKLCH in GSAP

GSAP accepts OKLCH color strings in modern browsers:

gsap.to(".element", {
  color: "oklch(60% 0.2 260deg)",
  duration: 0.5,
  ease: "power2.out"
});

For keyframe sequences with consistent perceived saturation:

gsap.to(".hero", {
  keyframes: [
    { backgroundColor: "oklch(55% 0.22 47deg)", duration: 1 },   // Orange
    { backgroundColor: "oklch(55% 0.22 155deg)", duration: 1 },  // Green
    { backgroundColor: "oklch(55% 0.22 260deg)", duration: 1 },  // Blue
    { backgroundColor: "oklch(55% 0.22 47deg)", duration: 1 },   // Back to orange
  ],
  repeat: -1,
  ease: "sine.inOut"
});

All four colors share 55% lightness and 0.22 chroma — only the hue changes. The perceived brightness and saturation remain constant throughout the loop.


Performance Tips

Color animation involves repainting pixels. Understanding the cost model helps you design animations that stay smooth on mid-range mobile devices.

The Rendering Pipeline

Animation Type Pipeline Stage Cost
transform, opacity Compositor (GPU) Near zero
background-color, color, border-color Paint (CPU + GPU) Low, occasional repaint
width, height, margin Layout + Paint High — avoid

Color property animation triggers a repaint on each frame — the browser recalculates pixel colors for the affected element. This is generally fast, but the cost scales with the area being repainted.

Minimize Repaint Area

For large-surface color animations (full-viewport backgrounds, large hero sections), avoid direct color property animation and instead animate the opacity of a positioned overlay:

.hero {
  position: relative;
  background-color: #1E3A8A;
}

.hero::after {
  content: "";
  position: absolute;
  inset: 0;
  background-color: #7C3AED;
  opacity: 0;
  transition: opacity 800ms ease;
  pointer-events: none;
}

.hero.active::after {
  opacity: 1;
}

The overlay technique achieves the appearance of a color change using only opacity animation, which is compositor-only and does not trigger a repaint.

will-change for Continuous Animation

For elements with always-on animation (loading indicators, ambient background effects):

.always-animating {
  will-change: background-color;
}

This tells the browser to promote the element to its own compositor layer. Use sparingly — each will-change declaration consumes GPU memory. Apply it only to elements that are continuously animating, not elements that animate occasionally on interaction.

Respect prefers-reduced-motion

Continuous color animation can cause vestibular discomfort for users with motion sensitivity. Always provide a reduced-motion fallback:

.pulse-animation {
  animation: status-pulse 2s ease-in-out infinite;
}

@media (prefers-reduced-motion: reduce) {
  .pulse-animation {
    animation: none;
    background-color: #10B981; /* Static color */
  }
}

In Framer Motion, the useReducedMotion hook provides programmatic access:

import { useReducedMotion } from "framer-motion";

function AnimatedBadge() {
  const shouldReduce = useReducedMotion();
  return (
    <motion.span
      animate={shouldReduce ? {} : { backgroundColor: ["#10B981", "#34D399", "#10B981"] }}
      transition={{ duration: 2, repeat: Infinity }}
    />
  );
}

GSAP gsap.matchMedia() for Motion Preferences

const mm = gsap.matchMedia();

mm.add("(prefers-reduced-motion: no-preference)", () => {
  gsap.to(".hero-bg", {
    backgroundColor: "#7C3AED",
    scrollTrigger: { scrub: true }
  });
});

Choosing the Right Tool

Scenario Recommended Approach
Hover/focus state color change CSS transition
Continuously looping ambient effect CSS @keyframes
Scroll-linked color transformation GSAP + ScrollTrigger
Complex choreographed sequence GSAP timeline
React component state-driven color Framer Motion variants
Pointer/cursor-reactive color Framer Motion useMotionValue
SVG icon color changes GSAP (handles fill/stroke natively)
Gradient color animation @property + CSS, or GSAP ColorProps

The rule is simple: reach for CSS first, add GSAP when you need timeline control or scroll integration, and use Framer Motion when you are in React and want declarative animation that co-locates with component state.

For finding the exact hex, HSL, and OKLCH values to use in your animations, ColorFYI's Color Converter converts between all major color formats. For building animated gradient color stops, the Gradient Generator lets you visualize the full range before committing to code.


Key Takeaways

  • CSS transitions cover the majority of UI color animation needs with zero JavaScript and excellent browser performance.
  • Register custom properties as <color> types with @property to enable smooth interpolation of CSS custom properties, including gradient color stops.
  • GSAP handles color strings natively (hex, RGB, HSL, OKLCH) and provides precise timeline control and scroll integration that CSS cannot replicate.
  • Framer Motion's variant system and useMotionValue hooks are the cleanest solution for React-based color animation, especially for state-driven color changes.
  • OKLCH interpolation produces perceptually smoother transitions than sRGB because it maintains constant perceived lightness and saturation — only hue changes. Add in oklch to CSS gradient declarations and use OKLCH values in keyframe animations for professional-quality color motion.
  • Color animation triggers a repaint, not a layout change. Minimize repaint area by using opacity-based overlay techniques for large surfaces.
  • Always implement @media (prefers-reduced-motion: reduce) overrides — this is a WCAG requirement, not an optional courtesy.

Màu sắc liên quan

Thương hiệu liên quan

Công cụ liên quan