| |

Tailwind CSS 28 🎨 CSS Animations and Custom Keyframe Definitions

A transition responds to a state change. An animation runs on its own. It starts when the element appears, loops if configured, and ends when the animation completes. Transitions are for interactions—hover, focus, active. Animations are for motion that does not depend on user input: a spinner, a pulse, a fade-in, a shake. Tailwind provides a small set of built-in animations and a mechanism for defining custom ones through the theme. This chapter covers the built-in utilities, the @theme configuration that defines keyframes, and the patterns for composing animations with transitions.

Key point: Tailwind’s animation utilities reference keyframes defined in the theme. The animate-* utility sets the animation property, which includes the keyframe name, duration, timing function, and iteration count. Custom animations are defined by declaring both the --animate-* value in @theme and the @keyframes block that the value references.


Why animation utilities exist

The self-starting problem. A transition needs a trigger. It waits for a property to change, and then it animates from the old value to the new one. An animation does not wait. It starts when the element is rendered and runs for the specified duration. A loading spinner should start spinning when it appears, not when the user hovers over it. The animation utility provides this self-starting behavior.

The keyframe problem. A transition interpolates between two states—from the current value to the target value. An animation can have any number of intermediate keyframes: 0% to 50% to 100%, or a sequence of steps. The @keyframes rule defines these steps, and the animation property references them. Tailwind’s built-in animations are defined this way, and custom animations follow the same pattern.

The looping problem. A transition runs once and stops. An animation can loop indefinitely. A pulse effect runs forever. A spinner rotates continuously. The animation-iteration-count property controls how many times the animation runs, and infinite makes it loop forever. Tailwind’s built-in animations set this value appropriately for each effect.

The composition problem. Animations and transitions can coexist. An element can have a transition for its hover state and an animation for its idle state. The two do not conflict because they affect different properties. Tailwind’s utilities compose without interference.

The theme problem. The built-in animations cover the common cases: spin, ping, pulse, bounce. Custom animations require defining keyframes and referencing them. The @theme directive in Tailwind v4 provides a way to define both in CSS, making them available as utilities without a plugin or a separate configuration file.


a. Built-in animation utilities

Tailwind provides four built-in animations, each defined with keyframes in the default theme.

UtilityEffectKeyframes
animate-spinContinuous rotation0deg to 360deg, linear, infinite
animate-pingExpanding fade outscale(1) opacity 1 to scale(2) opacity 0, infinite
animate-pulseGentle opacity pulseOpacity 1 to 0.5 to 1, infinite
animate-bounceVertical bounceTranslate Y up and down, infinite

The animate-spin utility is used for loading spinners:

<div class="animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent"></div>

The circular border with a transparent top creates the spinner. The animate-spin rotates it continuously.

The animate-ping utility creates a radar-like expanding circle:

<span class="relative flex h-3 w-3">
  <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
  <span class="relative inline-flex rounded-full h-3 w-3 bg-sky-500"></span>
</span>

The animate-ping element expands and fades, while the static element stays in place. This is the standard pattern for a notification indicator.

The animate-pulse utility creates a subtle breathing effect:

<div class="animate-pulse bg-gray-300 h-4 w-48 rounded"></div>

This is commonly used for skeleton loading states, where the placeholder pulses while data loads.

The animate-bounce utility creates a vertical bounce:

<div class="animate-bounce">↓</div>

This is often used for scroll indicators or attention-drawing elements.

Each built-in animation can be modified with utilities that override the default timing:

<div class="animate-spin duration-1000">Slow spin</div>
<div class="animate-pulse duration-3000">Slow pulse</div>

The duration-* utility overrides the animation’s default duration. The ease-* utility overrides the timing function. The delay-* utility postpones the start. The direction-* utility reverses or alternates the direction. The iteration-* utility controls how many times it repeats.


b. Defining custom animations with @theme

Custom animations are defined in two parts: the keyframes and the animation utility. In Tailwind v4, both are declared in CSS using the @theme directive.

@import "tailwindcss";

@theme {
  --animate-fade-in: fade-in 0.5s ease-out;
  --animate-slide-up: slide-up 0.3s ease-out;
  --animate-shake: shake 0.5s ease-in-out;
}

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes slide-up {
  from { transform: translateY(20px); opacity: 0; }
  to { transform: translateY(0); opacity: 1; }
}

@keyframes shake {
  0%, 100% { transform: translateX(0); }
  25% { transform: translateX(-5px); }
  75% { transform: translateX(5px); }
}

The --animate-* variables define the animation values. The value is the full animation shorthand: keyframe name, duration, timing function, and any other properties. The utility name is derived from the variable name: --animate-fade-in becomes animate-fade-in.

The @keyframes blocks define the steps. The name in the @keyframes must match the name referenced in the --animate-* value.

With these definitions, the utilities are available:

<div class="animate-fade-in">Fades in on mount</div>
<div class="animate-slide-up">Slides up on mount</div>
<div class="animate-shake">Shakes on mount</div>

The animation runs when the element is rendered. It does not loop unless the value includes infinite.

For a looping animation, include the iteration count in the value:

@theme {
  --animate-float: float 3s ease-in-out infinite;
}

@keyframes float {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-10px); }
}

The infinite keyword makes the animation loop forever.

For an animation that should run once and stop at the final state, use forwards:

@theme {
  --animate-fade-in-once: fade-in 0.5s ease-out forwards;
}

The forwards keyword keeps the final keyframe’s values after the animation completes. Without it, the element snaps back to its original state.


c. Combining animations with transitions and variants

An animation and a transition can coexist on the same element. The animation runs on mount, and the transition responds to hover.

<div class="animate-fade-in transition-transform hover:scale-105">
  Fades in, then scales on hover
</div>

The animate-fade-in runs once when the element appears. The transition-transform enables the hover scale. The two do not conflict because the animation affects opacity and the transition affects transform. If they affected the same property, the animation would take precedence while it is running.

Animations respond to variants. The hover:animate-* utility starts the animation on hover:

<div class="hover:animate-shake">
  Shakes on hover
</div>

The group-hover:animate-* utility starts the animation when the parent is hovered:

<div class="group">
  <button>Trigger</button>
  <div class="group-hover:animate-bounce">Bounces when button is hovered</div>
</div>

The motion-reduce variant disables animations for users who have requested reduced motion:

<div class="animate-bounce motion-reduce:animate-none">
  Bounces unless reduced motion is preferred
</div>

The motion-reduce:animate-none utility removes the animation when the user has set prefers-reduced-motion: reduce. This is an accessibility requirement, not a nicety. Large motion can cause discomfort for users with vestibular disorders.

A custom animation can be defined for a specific state:

@theme {
  --animate-wiggle: wiggle 0.3s ease-in-out;
}

@keyframes wiggle {
  0%, 100% { transform: rotate(0deg); }
  25% { transform: rotate(-5deg); }
  75% { transform: rotate(5deg); }
}
<div class="hover:animate-wiggle motion-reduce:animate-none">
  Wiggles on hover
</div>

The animation runs once on hover and stops. It does not loop because the value does not include infinite.


Complete Example Session

<!-- ============================================ -->
<!-- PART 1: ANIMATE-SPIN -->
<!-- ============================================ -->
<div class="animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent"></div>
<!-- ============================================ -->
<!-- PART 2: ANIMATE-PING -->
<!-- ============================================ -->
<span class="relative flex h-3 w-3">
  <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
  <span class="relative inline-flex rounded-full h-3 w-3 bg-sky-500"></span>
</span>
<!-- ============================================ -->
<!-- PART 3: ANIMATE-PULSE -->
<!-- ============================================ -->
<div class="animate-pulse bg-gray-300 h-4 w-48 rounded"></div>
<!-- ============================================ -->
<!-- PART 4: ANIMATE-BOUNCE -->
<!-- ============================================ -->
<div class="animate-bounce">↓</div>
<!-- ============================================ -->
<!-- PART 5: OVERRIDING DURATION -->
<!-- ============================================ -->
<div class="animate-spin duration-1000">Slow spin</div>
<div class="animate-spin duration-500">Normal spin</div>
<!-- ============================================ -->
<!-- PART 6: CUSTOM ANIMATION IN @theme -->
<!-- ============================================ -->
<style>
@theme {
  --animate-fade-in: fade-in 0.5s ease-out;
  --animate-slide-up: slide-up 0.3s ease-out;
}

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes slide-up {
  from { transform: translateY(20px); opacity: 0; }
  to { transform: translateY(0); opacity: 1; }
}
</style>

<div class="animate-fade-in">Fades in</div>
<div class="animate-slide-up">Slides up</div>
<!-- ============================================ -->
<!-- PART 7: LOOPING CUSTOM ANIMATION -->
<!-- ============================================ -->
<style>
@theme {
  --animate-float: float 3s ease-in-out infinite;
}

@keyframes float {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-10px); }
}
</style>

<div class="animate-float">Floats forever</div>
<!-- ============================================ -->
<!-- PART 8: ANIMATION WITH FORWARDS -->
<!-- ============================================ -->
<style>
@theme {
  --animate-fade-in-once: fade-in 0.5s ease-out forwards;
}
</style>

<div class="animate-fade-in-once">Stays visible after fade</div>
<!-- ============================================ -->
<!-- PART 9: ANIMATION WITH TRANSITION -->
<!-- ============================================ -->
<div class="animate-fade-in transition-transform hover:scale-105">
  Fades in, scales on hover
</div>
<!-- ============================================ -->
<!-- PART 10: REDUCED MOTION -->
<!-- ============================================ -->
<div class="animate-bounce motion-reduce:animate-none">
  Bounces unless reduced motion is preferred
</div>

The ten parts covered animate-spin, animate-ping, animate-pulse, animate-bounce, overriding duration, custom animations in @theme, looping animations, forwards, animation with transition, and reduced motion.


Quick Reference

Built-in Animations

UtilityEffectDefault Duration
animate-spinRotate 360deg1s
animate-pingScale and fade1s
animate-pulseOpacity pulse2s
animate-bounceVertical bounce1s

Animation Control Utilities

UtilityEffect
duration-*Override duration
ease-*Override timing function
delay-*Postpone start
direction-normalNormal direction
direction-reverseReverse direction
direction-alternateAlternate on each iteration
iteration-1Run once
iteration-infiniteRun forever
fill-noneNo fill mode
fill-forwardsKeep final state

Custom Animation Definition

PartWhereExample
Animation value@theme--animate-fade-in: fade-in 0.5s ease-out;
Keyframes@keyframes@keyframes fade-in { ... }
Utility nameDerivedanimate-fade-in

Common Keyframe Patterns

PatternKeyframes
Fade infrom { opacity: 0 } to { opacity: 1 }
Slide upfrom { transform: translateY(20px) } to { transform: translateY(0) }
Shake0%,100% { translateX(0) } 25% { translateX(-5px) } 75% { translateX(5px) }
Float0%,100% { translateY(0) } 50% { translateY(-10px) }
Wiggle0%,100% { rotate(0) } 25% { rotate(-5deg) } 75% { rotate(5deg) }

Best Practices

✅ Do This:

<!-- Use animate-spin for spinners -->
<div class="animate-spin">⟳</div>                              <!-- ✅ -->

<!-- Use animate-pulse for skeletons -->
<div class="animate-pulse bg-gray-300 h-4 w-48"></div>         <!-- ✅ -->

<!-- Define custom animations in @theme -->
@theme { --animate-fade-in: fade-in 0.5s ease-out; }           <!-- ✅ -->

<!-- Include keyframes with the same name -->
@keyframes fade-in { from { opacity: 0 } to { opacity: 1 } }    <!-- ✅ -->

<!-- Use forwards to keep final state -->
--animate-fade-in: fade-in 0.5s ease-out forwards;             <!-- ✅ -->

<!-- Add motion-reduce for accessibility -->
<div class="animate-bounce motion-reduce:animate-none">        <!-- ✅ -->

<!-- Combine animation with transition -->
<div class="animate-fade-in transition-transform hover:scale-105"> <!-- ✅ -->

❌ Don’t Do This:

<!-- Don't use animations for hover feedback -->
<div class="animate-pulse hover:animate-spin">                 <!-- ⚠️ use transition -->

<!-- Don't forget the keyframes -->
--animate-fade-in: fade-in 0.5s;  /* no @keyframes */          <!-- ❌ -->

<!-- Don't use infinite for entrances -->
--animate-fade-in: fade-in 0.5s infinite;                      <!-- ❌ loops -->

<!-- Don't skip reduced motion for large animation -->
<div class="animate-bounce">                                   <!-- ❌ -->

<!-- Don't animate layout properties -->
@keyframes grow { from { width: 0 } to { width: 100% } }       <!-- ⚠️ jank -->

<!-- Don't use the same animation for everything -->
<div class="animate-pulse">Every element</div>                 <!-- ❌ -->

Common Pitfalls

PitfallWhy It HappensFix
Animation does not runKeyframes not definedAdd @keyframes block
Animation name mismatchTypo in keyframe nameMatch --animate-* value to keyframes
Element snaps backNo forwards fill modeAdd forwards to the value
Animation loops unexpectedlyinfinite in the valueRemove for one-shot animations
Animation too fastDefault durationOverride with duration-*
Motion causes discomfortNo reduced-motion variantAdd motion-reduce:animate-none
Janky animationAnimating layout propertiesAnimate transform and opacity

Real-World Examples

1. Loading Spinner

<div class="animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent"></div>

2. Notification Ping

<span class="relative flex h-3 w-3">
  <span class="animate-ping absolute h-full w-full rounded-full bg-sky-400 opacity-75"></span>
  <span class="relative rounded-full h-3 w-3 bg-sky-500"></span>
</span>

3. Skeleton Loader

<div class="animate-pulse space-y-4">
  <div class="h-4 bg-gray-300 rounded w-3/4"></div>
  <div class="h-4 bg-gray-300 rounded w-1/2"></div>
</div>

4. Fade In on Mount

<div class="animate-fade-in">Content</div>

5. Slide Up

<div class="animate-slide-up">Panel</div>

6. Shake on Error

<div class="animate-shake">Invalid input</div>

7. Floating Element

<div class="animate-float">Decorative element</div>

8. Scroll Indicator

<div class="animate-bounce">↓ Scroll for more</div>

9. Hover Wiggle

<div class="hover:animate-wiggle motion-reduce:animate-none">Interactive</div>

10. Reduced Motion Fallback

<div class="animate-bounce motion-reduce:animate-none">Safe motion</div>

Visual

Animation vs Transition

┌─────────────────────────────────────────────────────────────┐
│  TRANSITION                                                 │
│                                                             │
│  Needs a trigger (hover, focus, state change).              │
│  Interpolates between two values.                           │
│  Runs once, then stops.                                     │
│  Example: bg-blue-500 → bg-blue-600 on hover                │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ANIMATION                                                  │
│                                                             │
│  Starts on its own when the element renders.                │
│  Can have multiple keyframes.                               │
│  Can loop indefinitely.                                     │
│  Example: continuous rotation, pulse, bounce                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Custom Animation Definition

┌─────────────────────────────────────────────────────────────┐
│  @theme {                                                   │
│    --animate-fade-in: fade-in 0.5s ease-out;                │
│  }                                                          │
│                                                             │
│  @keyframes fade-in {                                       │
│    from { opacity: 0; }                                     │
│    to   { opacity: 1; }                                     │
│  }                                                          │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  --animate-fade-in                                  │    │
│  │     │                                               │    │
│  │     ├── keyframe name: fade-in                      │    │
│  │     ├── duration: 0.5s                              │    │
│  │     └── easing: ease-out                            │    │
│  │                                                     │    │
│  │  @keyframes fade-in                                 │    │
│  │     ├── from: opacity 0                             │    │
│  │     └── to: opacity 1                               │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  Result: animate-fade-in utility                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Fill Mode

┌─────────────────────────────────────────────────────────────┐
│  WITHOUT forwards                                           │
│                                                             │
│  ──────████████████████─────────────────                    │
│  Start  Animation      End  Snap back to start             │
│                                                             │
│  Element returns to its original state.                     │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  WITH forwards                                              │
│                                                             │
│  ──────██████████████████████████████████                   │
│  Start  Animation      End  Stays at final state           │
│                                                             │
│  Element keeps the final keyframe values.                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Reduced Motion

┌─────────────────────────────────────────────────────────────┐
│  DEFAULT                                                    │
│                                                             │
│  .animate-bounce                                            │
│  Element bounces continuously.                              │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  WITH motion-reduce:animate-none                            │
│                                                             │
│  @media (prefers-reduced-motion: reduce) {                  │
│    animation: none;                                         │
│  }                                                          │
│                                                             │
│  Element does not bounce for users who have requested       │
│  reduced motion.                                            │
│                                                             │
│  Required for accessibility.                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
Built-in animationsanimate-spin, animate-ping, animate-pulse, animate-bounce
Custom animation@theme { --animate-*: ... } + @keyframes
Utility nameDerived from variable name
Keyframe nameMust match the value in --animate-*
One-shotOmit infinite
Keep final stateAdd forwards
Loop foreverAdd infinite
Override durationduration-*
Disableanimate-none
Reduced motionmotion-reduce:animate-none

Key takeaways:

  • Animations run on their own; transitions respond to state changes. An animation starts when the element renders. A transition waits for a property to change. Use animations for spinners, pulses, and entrance effects. Use transitions for hover, focus, and active states.
  • Tailwind provides four built-in animations. animate-spin for rotation, animate-ping for expanding fade, animate-pulse for opacity pulse, and animate-bounce for vertical bounce. Each is defined with keyframes in the default theme.
  • Custom animations are defined in @theme with @keyframes. The --animate-* variable holds the animation shorthand. The @keyframes block defines the steps. The keyframe name in the value must match the name in the block.
  • The utility name comes from the variable name. --animate-fade-in becomes animate-fade-in. The variable defines the animation, and the utility applies it.
  • forwards keeps the final state. Without it, an element snaps back to its original state after the animation completes. With it, the final keyframe’s values persist.
  • infinite makes the animation loop. Omit it for one-shot animations. Include it for continuous effects like spinning and pulsing.
  • The animation control utilities override the defaults. duration-*, ease-*, delay-*, direction-*, and iteration-* adjust the animation without changing the keyframes.
  • motion-reduce:animate-none is required for accessibility. Users with vestibular disorders can request reduced motion through their operating system. Large motion—bouncing, shaking, spinning—should be disabled when that preference is set.

Remember: Animations and transitions are two different tools for two different jobs. Transitions smooth state changes. Animations provide motion that does not depend on user input. Tailwind provides four built-in animations and a mechanism for defining custom ones through @theme and @keyframes. The animation value in @theme holds the full shorthand, and the keyframe name in that value must match the @keyframes block. Use forwards to keep the final state, infinite to loop, and the control utilities to override the timing. And always include motion-reduce:animate-none for large motion. The utilities are small, but the effects they produce—spinners, pulses, fades, bounces—are the ones that make an interface feel alive and responsive.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!