Tutorials

Color Animation in CSS: Transitions and Keyframes

10 min read

Color animation is one of the most expressive tools available to a web designer. A button that shifts hue on hover, a background that cycles through a gradient at dawn and dusk, a loading indicator whose color pulses with energy — these effects establish mood, communicate state, and add life to an interface without a single line of JavaScript. But CSS color animation has nuances that trip up even experienced developers: which properties animate, how the browser interpolates between colors, why some transitions look muddy in the middle, and how to use modern color spaces to get smooth, perceptually natural results.

This guide covers CSS color transitions, keyframe animations, HSL-based hue rotation, OKLCH interpolation for perceptual smoothness, and performance considerations that keep animations buttery on every device.


CSS Color Transitions

The transition property tells the browser to animate smoothly from one value to another whenever a CSS property changes. Color properties — color, background-color, border-color, outline-color, fill, stroke, and others — are all animatable.

Basic Syntax

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

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

When the cursor enters the button, the browser animates from #3B82F6 (Tailwind blue-500) to #1D4ED8 (Tailwind blue-700) over 200 milliseconds. When the cursor leaves, it animates back.

The transition shorthand accepts: property duration easing-function delay. Multiple transitions can be comma-separated:

.card {
  background-color: #F8FAFC;
  border-color: #E2E8F0;
  transition:
    background-color 300ms ease,
    border-color 300ms ease;
}

.card:hover {
  background-color: #EFF6FF;
  border-color: #BFDBFE;
}

Transition Timing Functions

The easing function controls the rate of change during the animation:

Keyword Behavior
ease Slow start, fast middle, slow end (default)
ease-in Slow start, fast finish
ease-out Fast start, slow finish
ease-in-out Slow start and end, fast middle
linear Constant rate throughout
cubic-bezier(x1, y1, x2, y2) Custom curve

For color transitions, ease-out often feels most natural — the color quickly reaches its approximate target and then settles, which matches how physical color perception works.

Transitioning color and Multiple Properties

You can use all to transition every animatable property, but this can cause unintended side effects if other properties change simultaneously. Being explicit is better practice:

.link {
  color: #6366F1;
  text-decoration-color: transparent;
  transition:
    color 150ms ease-out,
    text-decoration-color 150ms ease-out;
}

.link:hover {
  color: #4338CA;
  text-decoration-color: #4338CA;
}

Keyframe Color Animations

The @keyframes rule defines a named animation sequence that can play continuously, repeat a set number of times, or run on a trigger. Unlike transitions, keyframes work without a state change — they can run automatically on page load or loop indefinitely.

Basic Keyframe Syntax

@keyframes pulse-blue {
  0%   { background-color: #3B82F6; }
  50%  { background-color: #60A5FA; }
  100% { background-color: #3B82F6; }
}

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

This creates a pulsing status indicator that oscillates between #3B82F6 (blue-500) and #60A5FA (blue-400), giving a subtle breathing effect.

Multiple Color Stops in Keyframes

@keyframes sunrise {
  0%   { background-color: #0F172A; }   /* Deep night */
  25%  { background-color: #7C3AED; }   /* Pre-dawn violet */
  50%  { background-color: #F97316; }   /* Dawn orange */
  75%  { background-color: #FDE68A; }   /* Morning yellow */
  100% { background-color: #BFDBFE; }   /* Clear sky blue */
}

.hero-background {
  animation: sunrise 8s ease-in-out forwards;
}

The colors shift through #0F172A → #7C3AED → #F97316 → #FDE68A → #BFDBFE, creating a cinematic time-of-day effect.

Animation Properties

The animation shorthand accepts: name duration timing-function delay iteration-count direction fill-mode play-state

.element {
  /* Name | Duration | Easing | Delay | Iterations | Direction */
  animation: pulse-blue 2s ease-in-out 0s infinite alternate;
}

The alternate direction makes the animation play forward then backward, creating a smooth loop without a hard jump at the end. This is ideal for color pulse effects.


HSL-Based Animation for Smooth Hue Rotation

HSL (Hue, Saturation, Lightness) makes hue rotation trivial because the hue is a single numeric value in degrees around the color wheel. Animating from hue 0 to hue 360 cycles through every color of the spectrum.

CSS Custom Properties for HSL Animation

The most elegant approach uses CSS custom properties combined with @keyframes:

:root {
  --hue: 220;
}

.rainbow-button {
  background-color: hsl(var(--hue) 70% 55%);
  transition: background-color 100ms linear;
}

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

.rainbow-button {
  animation: hue-rotate 4s linear infinite;
}

However, there is a catch: CSS cannot interpolate between custom property values by default because the browser does not know the property type. This is where @property comes in.

The @property Rule for Animated Custom Properties

@property registers a custom property with a specific type, enabling the browser to interpolate it smoothly:

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

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

.rainbow-text {
  color: hsl(var(--hue) 80% 45%);
  animation: hue-rotate 6s linear infinite;
}

With @property declaring --hue as a <number>, the browser interpolates smoothly between 0 and 360, cycling through the full hue wheel.

Partial Hue Rotation for Themed Effects

You do not need a full 360-degree rotation to create interesting effects. Animating within a narrower hue range creates a more restrained, on-brand feel:

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

@keyframes ocean-shift {
  0%   { --hue: 195; }   /* Cyan */
  50%  { --hue: 230; }   /* Blue */
  100% { --hue: 195; }   /* Back to cyan */
}

.ocean-card {
  background-color: hsl(var(--hue) 60% 45%);
  animation: ocean-shift 5s ease-in-out infinite;
}

This shifts between a cyan comparable to #00BFCE and a medium blue comparable to #4166CC, creating a fluid ocean-tidal effect without ever leaving the blue-cyan family.

Lightness and Saturation Animation

Beyond hue, you can animate saturation and lightness to create pulsing, fading, or energizing effects:

@property --lightness {
  syntax: "<percentage>";
  initial-value: 55%;
  inherits: false;
}

@keyframes pulse-light {
  0%, 100% { --lightness: 45%; }
  50%       { --lightness: 65%; }
}

.pulse {
  background-color: hsl(220 70% var(--lightness));
  animation: pulse-light 2s ease-in-out infinite;
}

This creates a gentle brightness pulse in a blue hue, ideal for notification badges or attention-drawing elements.


OKLCH Interpolation for Perceptual Smoothness

HSL-based animation is convenient, but it has a well-known problem: the midpoints of transitions between certain hues look visually unpleasant — passing through muddy, desaturated gray zones, or through intermediate hues the designer did not intend.

This happens because HSL and sRGB are not perceptually uniform. Equal numeric steps do not produce equal perceived steps. The interpolation path between two HSL colors passes through intermediate colors that are not perceptually halfway between the endpoints.

OKLCH (OK Lightness-Chroma-Hue) solves this. It is designed so that equal numeric steps produce equal perceived steps. Transitions between OKLCH colors produce smooth, vibrant midpoints without unexpected gray zones or hue shifting.

Animating with OKLCH

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

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

.smooth-rainbow {
  color: oklch(60% 0.18 var(--oklch-hue));
  animation: perceptual-hue-rotate 8s linear infinite;
}

Compared to the same animation using hsl(), the OKLCH version maintains consistent perceived brightness throughout the rotation. The color never dips into a dark or pale zone as it passes through yellow, cyan, or other perceptually non-uniform regions of the HSL wheel.

Direct OKLCH Transitions

OKLCH interpolation is especially powerful for transition between two specific colors:

.button {
  /* From: vibrant purple [#7C3AED equivalent] */
  background-color: oklch(50% 0.22 285deg);
  transition: background-color 400ms ease;
}

.button:hover {
  /* To: vivid blue [#2563EB equivalent] */
  background-color: oklch(50% 0.22 255deg);
}

Because both colors have the same OKLCH lightness (50%) and chroma (0.22), the transition between them maintains constant perceived brightness and saturation throughout — only the hue changes. In sRGB or HSL, an equivalent transition would lighten or darken perceptibly in the middle.

Use ColorFYI's Color Converter to find the OKLCH equivalents of your hex brand colors, then use those values in your animation definitions for the smoothest results.

CSS Gradients with OKLCH Interpolation

OKLCH also improves gradient quality. CSS Color 4 allows you to specify the interpolation color space for gradients:

/* Standard sRGB interpolation — may show gray muddy zone in the middle */
background: linear-gradient(to right, #FF0000, #0000FF);

/* OKLCH interpolation — smooth, perceptually uniform */
background: linear-gradient(in oklch to right, oklch(60% 0.25 27deg), oklch(60% 0.25 265deg));

The in oklch keyword tells the browser to interpolate the gradient in OKLCH space. This keeps the perceived brightness constant through the midpoint and produces richer intermediate colors.

Pair this technique with ColorFYI's Gradient Generator to build your base gradient visually, then add in oklch for perceptual uniformity.

Hue Interpolation Direction in OKLCH

A useful OKLCH-specific option is controlling which direction around the hue wheel the interpolation travels:

/* Shorter arc (default) — may skip through unintended hues */
background: linear-gradient(in oklch to right,
  oklch(55% 0.2 60deg),    /* Yellow-orange */
  oklch(55% 0.2 300deg)    /* Magenta */
);

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

The shorter hue (default) always takes the shorter path around the hue wheel; longer hue takes the longer path. This gives you control over which intermediate hues appear in the gradient or animation.


Performance Considerations

Color animation involves the browser's rendering pipeline. Understanding where performance issues arise helps you design animations that remain smooth even on mid-range mobile devices.

The Safe Properties for Animation

CSS animations trigger different phases of the rendering pipeline. For performance, the goal is to stay in the compositing phase — handled by the GPU — and avoid triggering layout or paint.

Property Pipeline Phase Performance
transform, opacity Compositor Excellent
background-color, color, border-color Paint Good — occasional repaint
width, height, margin, padding Layout + Paint Expensive — avoid animating

Color animations (background-color, color) trigger a repaint on each frame — the browser must recalculate the pixel colors for the affected element. This is generally fast and GPU-accelerated on modern devices, but it is not as free as transform and opacity animation.

Minimizing Repaint Area

The repaint cost is proportional to the area being repainted. Animating the background color of a full-screen hero section repaints far more pixels than animating a small status indicator. For large-area color animations, consider:

Using a pseudo-element overlay: Animate the opacity of a colored pseudo-element on top of a static background. opacity is compositor-only and does not trigger repaint:

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

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

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

This achieves a "color change" effect by fading in a colored overlay, using only compositor-level operations.

Gradient Animation Restrictions

Standard CSS cannot interpolate between two different background-image gradient values. If you try to transition between two linear-gradient() values, the browser will snap between them rather than animating smoothly:

/* This will NOT animate smoothly */
.element {
  background: linear-gradient(45deg, #3B82F6, #8B5CF6);
  transition: background 500ms ease;  /* Snaps, does not animate */
}
.element:hover {
  background: linear-gradient(45deg, #EF4444, #F97316);
}

The solutions:

Option 1: Animate background-position on an oversized gradient

.element {
  background: linear-gradient(45deg, #3B82F6, #8B5CF6, #EF4444, #F97316);
  background-size: 300% 300%;
  background-position: 0% 50%;
  transition: background-position 800ms ease;
}
.element:hover {
  background-position: 100% 50%;
}

Option 2: Animate gradient color stops via @property

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

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

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

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

With @property declaring the custom properties as <color> type, the browser can interpolate between color values and the gradient animates smoothly.

Will-Change and GPU Hints

For elements with continuous color animation (always-on pulsing indicators, animated backgrounds), hint the browser to allocate GPU resources:

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

Use will-change sparingly — applying it to many elements simultaneously consumes significant GPU memory. Reserve it for elements that truly animate continuously.

Respect prefers-reduced-motion

Always provide a non-animated fallback for users who have requested reduced motion in their operating system accessibility settings. Continuous animations can cause nausea and vestibular discomfort for some users:

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

.animated-element {
  animation: hue-rotate 4s linear infinite;
}

@media (prefers-reduced-motion: reduce) {
  .animated-element {
    animation: none;
    /* Optionally set a specific static color */
    background-color: hsl(220 70% 55%);
  }
}

This is not just good practice — it is required for WCAG 2.3 AA conformance (Success Criterion 2.3.3 Animation from Interactions).


Key Takeaways

  • CSS color transitions use the transition property to animate between color states on hover, focus, or class changes; all color properties — background-color, color, border-color, and more — are animatable.
  • @keyframes enables multi-step color animations that loop continuously, run on load, or respond to triggers, with full control over easing and timing at each step.
  • HSL-based hue rotation is convenient for spectrum animations — animating the hue degree from 0 to 360 cycles the full color wheel. Use @property to declare custom properties as typed values so the browser interpolates them smoothly.
  • OKLCH produces perceptually smoother color transitions than HSL or sRGB, because equal OKLCH steps produce equal perceived steps — no muddy gray midpoints or unintended hue shifts. Use the Color Converter to find OKLCH values for your brand colors.
  • Use in oklch in gradient declarations (linear-gradient(in oklch ...)) to get perceptually uniform gradient interpolation with consistent brightness throughout.
  • CSS cannot interpolate between two different linear-gradient() values natively; use @property with <color> type custom properties, or animate background-position on an oversized gradient as a workaround.
  • Color animation triggers a repaint, not a layout change — it is generally fast, but minimize the repainted area for large-surface animations by using opacity-based overlay techniques or compositor-only alternatives.
  • Always include @media (prefers-reduced-motion: reduce) overrides to disable continuous animations for users with vestibular or motion sensitivity disorders.
  • Use ColorFYI's Gradient Generator to build animated gradient color stops visually, then refine them with in oklch interpolation for the smoothest on-screen results.

Related Colors

Related Brands

Related Tools