Tailwind CSS 64 🎨 Custom CSS Keyframes and Advanced Multi-Stage Keyframe Timing
Tailwind ships four animations by default: animate-spin, animate-ping, animate-pulse, and animate-bounce. They cover the common loading and attention states, and they are enough for a prototype. A production interface needs more: a notification that slides in and settles, a modal that scales up and fades, a skeleton that shimmers, a checkmark that draws itself. The @keyframes rule is the mechanism, and the Tailwind theme extension is how the keyframes and the animation utilities are registered. This chapter covers the registration, the multi-stage timing, and the patterns that make the animations feel designed.
Key point: A Tailwind animation utility is a CSS animation shorthand. The theme.extend.animation entry sets the shorthand, and the theme.extend.keyframes entry defines the @keyframes rule. The two are separate entries, and both are required for the animation to work.
Why custom keyframes matter
The default problem. The four built-in animations cover the loading spinner and the attention pulse. They do not cover the entrance, the exit, the morph, or the sequence. The custom keyframes are the answer.
The multi-stage problem. A notification that slides in and then fades requires two stages: the slide and the fade. A single from/to pair cannot express both. The percentage keyframes (0%, 50%, 100%) express the intermediate states.
The easing problem. A bounce requires the cubic-bezier easing. A spring requires the linear() easing function with the multiple points. The animation-timing-function and the per-keyframe easing control the feel.
The composition problem. A complex animation is the composition of the smaller ones. The modal entrance is the fade and the scale, and the two run at the same time. The animation shorthand accepts the comma-separated list.
The performance problem. The animations that trigger the layout (the width, the height, the top) run on the main thread. The animations that use the transform and the opacity run on the compositor. The rule is to animate the transform and the opacity where possible.
The reduced-motion problem. The animations can trigger the motion sickness. The motion-reduce: variant and the prefers-reduced-motion media query disable the animation for the users who opt out.
The design-system problem. The animations are part of the design system. The durations, the easings, and the keyframes are the tokens. The theme.extend is the registration, and the components use the utility names.
a. Registering the keyframes and the animations
The Tailwind theme has the keyframes and the animation entries. The keyframes defines the @keyframes rule, and the animation sets the animation shorthand.
The basic registration
// tailwind.config.js
export default {
theme: {
extend: {
keyframes: {
wiggle: {
'0%, 100%': { transform: 'rotate(-3deg)' },
'50%': { transform: 'rotate(3deg)' },
},
},
animation: {
wiggle: 'wiggle 1s ease-in-out infinite',
},
},
},
};
The keyframes.wiggle defines the rule, and the animation.wiggle sets the shorthand. The utility animate-wiggle is generated .
The usage
<div class="animate-wiggle">Wiggle</div>
The class applies the animation: wiggle 1s ease-in-out infinite.
The multi-stage keyframes
The percentage keyframes express the intermediate states.
keyframes: {
slideIn: {
'0%': { transform: 'translateY(-100%)', opacity: '0' },
'60%': { transform: 'translateY(10%)', opacity: '1' },
'80%': { transform: 'translateY(-5%)' },
'100%': { transform: 'translateY(0)' },
},
},
animation: {
slideIn: 'slideIn 0.6s ease-out',
},
The 0% is the start, the 60% overshoots, the 80% corrects, and the 100% settles. The multi-stage produces the bounce feel.
The per-keyframe easing
The animation-timing-function can be set inside the keyframe.
keyframes: {
bounce: {
'0%': { transform: 'translateY(0)', animationTimingFunction: 'ease-out' },
'50%': { transform: 'translateY(-25%)', animationTimingFunction: 'ease-in' },
'100%': { transform: 'translateY(0)' },
},
},
The easing changes at each stage, and the motion feels more natural.
The keyframe with the CSS variables
The keyframe can reference the CSS variables for the customization.
keyframes: {
slideIn: {
'0%': { transform: 'translateX(var(--slide-from, -100%))' },
'100%': { transform: 'translateX(0)' },
},
},
The --slide-from is the custom property, and the component sets it.
The registration table
| Entry | Purpose |
|---|---|
theme.extend.keyframes | The @keyframes rule |
theme.extend.animation | The animation shorthand |
animate-* | The generated utility |
b. The multi-stage timing
The multi-stage timing is the set of the techniques that make the animation feel designed rather than mechanical.
The anticipation
The anticipation is the small movement in the opposite direction before the main movement.
keyframes: {
anticipate: {
'0%': { transform: 'scale(1)' },
'20%': { transform: 'scale(0.9)' },
'100%': { transform: 'scale(1.1)' },
},
},
The 20% is the anticipation, and the 100% is the result.
The overshoot
The overshoot is the movement past the target and back.
keyframes: {
overshoot: {
'0%': { transform: 'translateY(100%)' },
'70%': { transform: 'translateY(-10%)' },
'100%': { transform: 'translateY(0)' },
},
},
The 70% passes the target, and the 100% settles.
The follow-through
The follow-through is the continuation of the motion after the main element stops.
keyframes: {
cardEnter: {
'0%': { transform: 'translateY(20px) scale(0.95)', opacity: '0' },
'60%': { transform: 'translateY(-4px) scale(1.02)', opacity: '1' },
'100%': { transform: 'translateY(0) scale(1)' },
},
},
The 60% overshoots, and the 100% settles.
The stagger
The stagger is the delay between the sibling elements.
<div class="animate-fadeIn [animation-delay:0ms]">Item 1</div>
<div class="animate-fadeIn [animation-delay:100ms]">Item 2</div>
<div class="animate-fadeIn [animation-delay:200ms]">Item 3</div>
The arbitrary value [animation-delay:100ms] sets the delay. The pattern is used for the list entrance.
The infinite loop
The infinite value repeats the animation.
animation: {
shimmer: 'shimmer 2s infinite',
},
keyframes: {
shimmer: {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
},
The infinite keyword repeats the shimmer.
The alternate direction
The alternate value reverses the animation on each iteration.
animation: {
pulse: 'pulse 1s ease-in-out infinite alternate',
},
The alternate produces the back-and-forth motion.
The fill mode
The forwards value keeps the final state after the animation.
animation: {
fadeIn: 'fadeIn 0.5s ease-out forwards',
},
The forwards keeps the 100% state, and the element does not revert.
The multi-stage table
| Technique | Purpose |
|---|---|
| Anticipation | The opposite movement before |
| Overshoot | The movement past the target |
| Follow-through | The continuation after |
| Stagger | The delay between the siblings |
| Infinite | The repeat |
| Alternate | The reverse on each iteration |
| Forwards | The keep the final state |
c. The common animation patterns
The patterns below are the ones that appear in the real interfaces.
The fade in
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
},
animation: {
fadeIn: 'fadeIn 0.5s ease-out',
},
The animate-fadeIn produces the fade.
The slide in
keyframes: {
slideInUp: {
'0%': { transform: 'translateY(100%)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
animation: {
slideInUp: 'slideInUp 0.4s ease-out',
},
The animate-slideInUp produces the entrance from the bottom.
The scale in
keyframes: {
scaleIn: {
'0%': { transform: 'scale(0.95)', opacity: '0' },
'100%': { transform: 'scale(1)', opacity: '1' },
},
},
animation: {
scaleIn: 'scaleIn 0.2s ease-out',
},
The animate-scaleIn produces the modal entrance.
The shimmer
keyframes: {
shimmer: {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
},
animation: {
shimmer: 'shimmer 2s linear infinite',
},
The animate-shimmer produces the skeleton loading.
<div class="animate-shimmer bg-gradient-to-r from-slate-200 via-slate-100 to-slate-200 bg-[length:200%_100%]">
</div>
The gradient and the background size produce the shimmer.
The bounce
keyframes: {
bounceIn: {
'0%': { transform: 'translateY(-25%)', opacity: '0' },
'50%': { transform: 'translateY(0)', opacity: '1' },
'70%': { transform: 'translateY(-10%)' },
'100%': { transform: 'translateY(0)' },
},
},
animation: {
bounceIn: 'bounceIn 0.6s ease-out',
},
The animate-bounceIn produces the entrance.
The spin with the delay
animation: {
'spin-slow': 'spin 3s linear infinite',
'spin-slower': 'spin 5s linear infinite',
},
The custom spin durations.
The draw
keyframes: {
draw: {
'0%': { strokeDashoffset: '100' },
'100%': { strokeDashoffset: '0' },
},
},
animation: {
draw: 'draw 1s ease-out forwards',
},
The animate-draw produces the checkmark drawing.
<svg class="h-12 w-12">
<path class="animate-draw stroke-current" stroke-dasharray="100" d="M 10 50 L 30 70 L 90 10" />
</svg>
The pattern table
| Pattern | Keyframes |
|---|---|
| Fade in | The opacity |
| Slide in | The translate + the opacity |
| Scale in | The scale + the opacity |
| Shimmer | The background position |
| Bounce in | The translate + the overshoot |
| Draw | The stroke offset |
Complete Example Session
// ============================================
// PART 1: THE BASIC KEYFRAMES
// ============================================
// tailwind.config.js
export default {
theme: {
extend: {
keyframes: {
wiggle: {
'0%, 100%': { transform: 'rotate(-3deg)' },
'50%': { transform: 'rotate(3deg)' },
},
},
animation: {
wiggle: 'wiggle 1s ease-in-out infinite',
},
},
},
};
// ============================================
// PART 2: THE MULTI-STAGE
// ============================================
keyframes: {
slideIn: {
'0%': { transform: 'translateY(-100%)', opacity: '0' },
'60%': { transform: 'translateY(10%)', opacity: '1' },
'80%': { transform: 'translateY(-5%)' },
'100%': { transform: 'translateY(0)' },
},
},
animation: {
slideIn: 'slideIn 0.6s ease-out',
},
// ============================================
// PART 3: THE PER-KEYFRAME EASING
// ============================================
keyframes: {
bounce: {
'0%': { transform: 'translateY(0)', animationTimingFunction: 'ease-out' },
'50%': { transform: 'translateY(-25%)', animationTimingFunction: 'ease-in' },
'100%': { transform: 'translateY(0)' },
},
},
// ============================================
// PART 4: THE CSS VARIABLE
// ============================================
keyframes: {
slideIn: {
'0%': { transform: 'translateX(var(--slide-from, -100%))' },
'100%': { transform: 'translateX(0)' },
},
},
// ============================================
// PART 5: THE STAGGER
// ============================================
// <div class="animate-fadeIn [animation-delay:0ms]">Item 1</div>
// <div class="animate-fadeIn [animation-delay:100ms]">Item 2</div>
// <div class="animate-fadeIn [animation-delay:200ms]">Item 3</div>
// ============================================
// PART 6: THE SHIMMER
// ============================================
keyframes: {
shimmer: {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
},
animation: {
shimmer: 'shimmer 2s linear infinite',
},
// <div class="animate-shimmer bg-gradient-to-r from-slate-200 via-slate-100 to-slate-200 bg-[length:200%_100%]" />
// ============================================
// PART 7: THE DRAW
// ============================================
keyframes: {
draw: {
'0%': { strokeDashoffset: '100' },
'100%': { strokeDashoffset: '0' },
},
},
animation: {
draw: 'draw 1s ease-out forwards',
},
// <svg><path class="animate-draw stroke-current" stroke-dasharray="100" d="..." /></svg>
// ============================================
// PART 8: THE REDUCED MOTION
// ============================================
// <div class="animate-slideIn motion-reduce:animate-none">...</div>
// @media (prefers-reduced-motion: reduce) {
// .animate-slideIn { animation: none; }
// }
// ============================================
// PART 9: THE COMPOSITION
// ============================================
// <div class="animate-fadeIn animate-scaleIn">...</div>
// The two animations run at the same time.
animation: {
'fadeIn-scaleIn': 'fadeIn 0.3s ease-out, scaleIn 0.3s ease-out',
},
// ============================================
// PART 10: THE COMPLETE PATTERN
// ============================================
// 1. Register the keyframes in theme.extend.keyframes.
// 2. Register the animation in theme.extend.animation.
// 3. Use the percentage keyframes for the multi-stage.
// 4. Use the per-keyframe easing for the natural motion.
// 5. Use the CSS variables for the customization.
// 6. Use the animation-delay for the stagger.
// 7. Use the fill-mode forwards for the final state.
// 8. Use the motion-reduce variant for the accessibility.
// 9. Use the transform and the opacity for the performance.
// 10. Use the comma-separated animation for the composition.
The ten parts showed the basic keyframes, the multi-stage, the per-keyframe easing, the CSS variable, the stagger, the shimmer, the draw, the reduced motion, the composition, and the complete pattern.
Quick Reference
The Theme Entries
| Entry | Purpose |
|---|---|
theme.extend.keyframes | The @keyframes rule |
theme.extend.animation | The animation shorthand |
theme.extend.transitionDuration | The custom durations |
theme.extend.transitionTimingFunction | The custom easings |
The Animation Shorthand
| Value | Purpose |
|---|---|
name duration timing delay iteration direction fill | The order |
The Common Properties
| Property | Purpose |
|---|---|
animation-name | The keyframes name |
animation-duration | The duration |
animation-timing-function | The easing |
animation-delay | The delay |
animation-iteration-count | The repeat |
animation-direction | The alternate |
animation-fill-mode | The forwards |
The Multi-Stage Techniques
| Technique | Keyframe Positions |
|---|---|
| Anticipation | 0%, 20%, 100% |
| Overshoot | 0%, 70%, 100% |
| Follow-through | 0%, 60%, 100% |
| Bounce | 0%, 50%, 70%, 100% |
The Animation Patterns
| Pattern | Keyframes |
|---|---|
| Fade in | The opacity |
| Slide in | The translate + the opacity |
| Scale in | The scale + the opacity |
| Shimmer | The background position |
| Bounce in | The translate + the overshoot |
| Draw | The stroke offset |
Best Practices
✅ Do This:
// Register the keyframes and the animation together.
keyframes: { wiggle: { ... } },
animation: { wiggle: 'wiggle 1s ease-in-out infinite' }, // ✅
// Use the percentage keyframes for the multi-stage.
keyframes: {
slideIn: {
'0%': { ... },
'60%': { ... },
'100%': { ... },
},
}, // ✅
// Use the per-keyframe easing.
'0%': { transform: 'translateY(0)', animationTimingFunction: 'ease-out' }, // ✅
// Use the CSS variables for the customization.
'0%': { transform: 'translateX(var(--slide-from, -100%))' }, // ✅
// Use the animation-delay for the stagger.
<div class="animate-fadeIn [animation-delay:100ms]"> // ✅
// Use the motion-reduce variant.
<div class="animate-slideIn motion-reduce:animate-none"> // ✅
// Use the transform and the opacity.
keyframes: { fadeIn: { '0%': { opacity: '0' }, '100%': { opacity: '1' } } }, // ✅
❌ Don’t Do This:
// Don't define the keyframes without the animation.
keyframes: { wiggle: { ... } }, // ❌ No animate-wiggle utility
// Don't use only the from/to for the multi-stage.
keyframes: {
slideIn: {
from: { transform: 'translateY(-100%)' },
to: { transform: 'translateY(0)' },
},
}, // ❌ No overshoot
// Don't animate the layout properties.
keyframes: {
grow: {
'0%': { width: '0' },
'100%': { width: '100%' },
},
}, // ❌ The main thread
// Don't forget the motion-reduce.
<div class="animate-spin"> // ❌ The motion sickness
// Don't use the same name for the keyframes and the utility.
animation: { wiggle: 'wiggle 1s' }, // ⚠️ The name collision is confusing
// Don't use a very long duration.
animation: { slow: 'spin 10s linear infinite' }, // ⚠️ The user waits
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Animation does not run | The animation entry is missing | Add the animation |
| Class not generated | The keyframes name mismatch | Match the names |
| No overshoot | Only from/to used | Use the percentage |
| Motion sickness | No reduced-motion | Add the motion-reduce: variant |
| Janky | The layout properties animated | Use the transform and the opacity |
| Reverts | The forwards is missing | Add the fill mode |
| Same delay | No animation-delay | Add the stagger |
Real-World Examples
1. The Wiggle
animation: { wiggle: 'wiggle 1s ease-in-out infinite' }
2. The Slide In
animation: { slideIn: 'slideIn 0.6s ease-out' }
3. The Per-Keyframe Easing
'0%': { transform: 'translateY(0)', animationTimingFunction: 'ease-out' }
4. The CSS Variable
'0%': { transform: 'translateX(var(--slide-from, -100%))' }
5. The Stagger
<div class="animate-fadeIn [animation-delay:100ms]">
6. The Shimmer
animation: { shimmer: 'shimmer 2s linear infinite' }
7. The Draw
animation: { draw: 'draw 1s ease-out forwards' }
8. The Reduced Motion
<div class="animate-slideIn motion-reduce:animate-none">
9. The Composition
animation: { 'fadeIn-scaleIn': 'fadeIn 0.3s, scaleIn 0.3s' }
10. The Complete Pattern
keyframes: { slideIn: { ... } },
animation: { slideIn: 'slideIn 0.6s ease-out' },
Visual
The Registration
┌────────────────────────────────────────────────────────────┐
│ THE REGISTRATION │
│ │
│ tailwind.config.js │
│ ┌──────────────────────────────────────────────────┐ │
│ │ theme: { │ │
│ │ extend: { │ │
│ │ keyframes: { │ │
│ │ wiggle: { │ │
│ │ '0%, 100%': { transform: 'rotate(-3deg)' },│ │
│ │ '50%': { transform: 'rotate(3deg)' }, │ │
│ │ }, │ │
│ │ }, │ │
│ │ animation: { │ │
│ │ wiggle: 'wiggle 1s ease-in-out infinite', │ │
│ │ }, │ │
│ │ }, │ │
│ │ } │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ The keyframes define the rule. The animation sets the │
│ shorthand. The animate-wiggle utility is generated. │
└────────────────────────────────────────────────────────────┘
The Multi-Stage Timing
┌────────────────────────────────────────────────────────────┐
│ THE MULTI-STAGE TIMING │
│ │
│ 0% ─────▶ 60% ─────▶ 80% ─────▶ 100% │
│ start overshoot correct settle │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ translateY(-100%) ← 0% │ │
│ │ translateY(10%) ← 60% (overshoot) │ │
│ │ translateY(-5%) ← 80% (correct) │ │
│ │ translateY(0) ← 100% (settle) │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ The overshoot and the correction produce the bounce. │
└────────────────────────────────────────────────────────────┘
The Stagger
┌────────────────────────────────────────────────────────────┐
│ THE STAGGER │
│ │
│ Item 1 ─────▶ (delay 0ms) │
│ Item 2 ─────▶ (delay 100ms) │
│ Item 3 ─────▶ (delay 200ms) │
│ Item 4 ─────▶ (delay 300ms) │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ <div class="animate-fadeIn [animation-delay:0ms]">│ │
│ │ <div class="animate-fadeIn [animation-delay:100ms]">│ │
│ │ <div class="animate-fadeIn [animation-delay:200ms]">│ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ The cascade of the delays produces the wave. │
└────────────────────────────────────────────────────────────┘
The Reduced Motion
┌────────────────────────────────────────────────────────────┐
│ THE REDUCED MOTION │
│ │
│ <div class="animate-slideIn motion-reduce:animate-none"> │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Without the preference: │ │
│ │ animation: slideIn 0.6s ease-out │ │
│ │ │ │
│ │ With prefers-reduced-motion: reduce: │ │
│ │ animation: none │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ The accessibility requirement. │
└────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
theme.extend.keyframes | The @keyframes rule |
theme.extend.animation | The animation shorthand |
animate-* | The generated utility |
| Multi-stage | The percentage keyframes |
| Per-keyframe easing | The animationTimingFunction inside the keyframe |
| Stagger | The animation-delay |
forwards | The keep the final state |
infinite | The repeat |
alternate | The reverse |
motion-reduce: | The accessibility variant |
Key takeaways:
- The keyframes and the animation are separate entries. The
theme.extend.keyframesdefines the rule, and thetheme.extend.animationsets the shorthand. Theanimate-*utility is generated from the animation entry. - The percentage keyframes express the multi-stage. The
0%, the60%, the80%, and the100%produce the overshoot, the correction, and the settle. Thefrom/topair cannot express the intermediate states. - The per-keyframe easing makes the motion natural. The
animationTimingFunctioninside the keyframe changes the easing at each stage. The anticipation, the overshoot, and the follow-through are the techniques. - The CSS variables make the keyframes customizable. The
var(--slide-from, -100%)is the default with the override. The component sets the custom property. - The
animation-delayproduces the stagger. The arbitrary value[animation-delay:100ms]sets the delay. The cascade of the delays produces the wave. - The
forwardsfill mode keeps the final state. The element does not revert after the animation. Theinfiniterepeats, and thealternatereverses. - The
motion-reduce:variant is the accessibility. Themotion-reduce:animate-nonedisables the animation for the users who prefer reduced motion. The 3D and the bouncing animations are the most common offenders. - The
transformand theopacityare the performant properties. The animations that use them run on the compositor. The layout properties run on the main thread, and the jank is the result.
Remember: The custom keyframes and the multi-stage timing are the mechanism for the animations that the default utilities do not cover. The theme.extend.keyframes defines the rule, and the theme.extend.animation sets the shorthand. The percentage keyframes produce the overshoot and the bounce, the per-keyframe easing makes the motion natural, and the animation-delay produces the stagger. The motion-reduce: variant is the accessibility, and the transform and the opacity are the performant properties. The result is an interface where the entrances and the exits feel designed, the loading states shimmer, and the checkmarks draw themselves.
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!