Animations
Add CSS transitions and animations to tour steps, respect prefers-reduced-motion, and create smooth step transitions
userTourKit provides smooth, performant animations out of the box while respecting users who prefer reduced motion. This guide covers customizing animations, understanding the built-in keyframes, and integrating with animation libraries.
Built-in Animations
userTourKit includes five core animations:
| Animation | Purpose | Duration |
|---|---|---|
tour-spotlight-in | Overlay fade-in | 200ms |
tour-card-in | Card entrance (scale + fade) | 200ms |
tour-pulse | Hint beacon pulse | 1.5s (infinite) |
| Tour card docking | Smooth re-positioning when the floating placement flips | 150ms |
| Checklist task completion | Strike-through label + check-icon scale on task complete | 200ms |
Default Keyframes
@keyframes tour-spotlight-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes tour-card-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes tour-pulse {
0%, 100% {
opacity: 1;
box-shadow: 0 0 0 0 hsl(var(--primary) / 0.7);
}
50% {
opacity: 1;
box-shadow: 0 0 0 8px hsl(var(--primary) / 0);
}
}CSS Custom Properties
Animation timing is controlled via CSS variables:
:root {
/* Duration presets */
--tour-duration-fast: 150ms;
--tour-duration-normal: 200ms;
--tour-duration-slow: 300ms;
/* Easing function */
--tour-timing: cubic-bezier(0.4, 0, 0.2, 1);
}Customizing Duration
Override the variables in your CSS:
:root {
/* Slower animations */
--tour-duration-normal: 300ms;
--tour-duration-slow: 500ms;
/* Custom easing */
--tour-timing: cubic-bezier(0.16, 1, 0.3, 1);
}Disabling Animations
Set durations to zero:
:root {
--tour-duration-fast: 0ms;
--tour-duration-normal: 0ms;
--tour-duration-slow: 0ms;
}Tailwind Integration
Using the Plugin
The userTourKit Tailwind plugin registers animations and utilities:
import { tourKitPlugin } from '@tour-kit/react/tailwind'
export default {
plugins: [tourKitPlugin],
}Available Utilities
After adding the plugin, you can use:
// Animation classes
<div className="animate-tour-pulse" />
<div className="animate-tour-spotlight-in" />
<div className="animate-tour-card-in" />
// Spotlight utilities
<div className="tour-spotlight-cutout" /> // 50% opacity overlay
<div className="tour-spotlight-cutout-light" /> // 30% opacity
<div className="tour-spotlight-cutout-dark" /> // 70% opacityCustom Animations in Tailwind
Extend the theme to add your own:
export default {
theme: {
extend: {
keyframes: {
'tour-slide-up': {
from: { opacity: '0', transform: 'translateY(10px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
'tour-bounce-in': {
'0%': { transform: 'scale(0.3)', opacity: '0' },
'50%': { transform: 'scale(1.05)' },
'70%': { transform: 'scale(0.9)' },
'100%': { transform: 'scale(1)', opacity: '1' },
},
},
animation: {
'tour-slide-up': 'tour-slide-up 200ms ease-out',
'tour-bounce-in': 'tour-bounce-in 400ms ease-out',
},
},
},
plugins: [tourKitPlugin],
}Reduced Motion Support
userTourKit automatically respects the prefers-reduced-motion media query.
Automatic Behavior
When users enable reduced motion:
@media (prefers-reduced-motion: reduce) {
:root {
--tour-duration-fast: 0ms;
--tour-duration-normal: 0ms;
--tour-duration-slow: 0ms;
}
.animate-tour-pulse,
.animate-tour-spotlight-in,
.animate-tour-card-in {
animation: none;
}
}What Changes
| Feature | Normal | Reduced Motion |
|---|---|---|
| Overlay | Fade in | Instant appear |
| Tour card | Scale + fade | Instant appear |
| Hint pulse | Continuous pulse | Static |
| Spotlight move | Animated | Instant |
| Video autoplay | Enabled | Shows poster |
| GIF autoplay | Enabled | Paused |
Using the Hook
Detect reduced motion preference in your components. There are two hooks:
useReducedMotion()— SSR-safe-default-true. Returnstrueon the server and on the first client render, then flips to the actualmatchMediavalue after the firstuseEffect. Use this for animation classes that must default to "no animation" during the first paint to avoid a one-frame motion flash for users who have requested reduced motion.usePrefersReducedMotion()— defaults tofalseserver-side. Use this when you need the raw client-side preference and SSR flicker is not a concern.
import { useReducedMotion } from '@tour-kit/core'
function MyAnimatedComponent() {
const reducedMotion = useReducedMotion()
return (
<div
className={cn(
'rounded-lg border p-4',
!reducedMotion && 'transition-transform duration-200 ease-out',
)}
>
Content
</div>
)
}Testing Reduced Motion
- Open DevTools (F12)
- Press Cmd/Ctrl + Shift + P
- Type "reduced motion"
- Select "Emulate CSS prefers-reduced-motion: reduce"
/* Force reduced motion for testing */
* {
animation-duration: 0ms !important;
transition-duration: 0ms !important;
}// In your test setup
vi.mock('@tour-kit/core', async () => {
const actual = await vi.importActual('@tour-kit/core')
return {
...actual,
usePrefersReducedMotion: () => true,
}
})Component-Specific Animations
Tour Card
The card uses tour-card-in by default:
import { TourCard } from '@tour-kit/react'
<TourCard
className="animate-tour-bounce-in" // Replace default animation
// ...props
/>Docking transition (placement flip)
<TourCard> uses @floating-ui/react to position itself near the current step's target. When the placement flips (e.g. bottom → top because the target scrolled near the viewport edge), Floating UI updates the inline transform synchronously. Tour Kit adds transition-[transform,top,left] duration-150 ease-out to the floating element so the move tweens smoothly instead of snapping.
The transition class is gated by useReducedMotion() — when the user prefers reduced motion, the class is omitted and the placement flip is instant. No additional configuration required.
// What Tour Kit applies internally:
<div
className={cn(
tourCardVariants({ size }),
'z-50',
!reducedMotion && 'transition-[transform,top,left] duration-150 ease-out',
)}
style={floatingStyles}
/>Overlay/Spotlight
The overlay fades in with tour-spotlight-in:
import { TourOverlay } from '@tour-kit/react'
<TourOverlay
className="animate-tour-slide-up"
// ...props
/>Checklist Task Completion
<ChecklistTask> (from @tour-kit/checklists) runs a 3-phase state machine when a task transitions to completed:
pending— default state.completing— 200ms transient; the root element gainsdata-tk-completing="true".completed— final state.
The completion animation is opt-in via a separate stylesheet so consumers who don't want the visual effect pay no CSS bytes:
import '@tour-kit/checklists/styles/animations.css'The stylesheet defines two keyframes (tk-strike, tk-check-pop) targeting [data-tk-completing="true"] .tk-task-label and [data-tk-completing="true"] .tk-task-icon, both wrapped in @media (prefers-reduced-motion: reduce) { animation: none }.
Defense-in-depth: the React side already skips the completing phase when useReducedMotion() is true, AND the CSS media query also stops the animation if a downstream consumer forces the attribute.
Hints
Hint beacons use tour-pulse:
import { HintHotspot } from '@tour-kit/hints'
<HintHotspot
className="animate-bounce" // Tailwind's bounce
// ...props
/>Framer Motion Integration
For advanced animations, integrate with Framer Motion:
Basic Integration
import { motion, AnimatePresence } from 'framer-motion'
import { useTour } from '@tour-kit/core'
function AnimatedTourCard({ children }) {
const { isActive } = useTour()
return (
<AnimatePresence>
{isActive && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -20 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
>
{children}
</motion.div>
)}
</AnimatePresence>
)
}Step Transitions
Animate between steps:
import { motion, AnimatePresence } from 'framer-motion'
import { useTour, useStep } from '@tour-kit/core'
function AnimatedStepContent() {
const { currentStepIndex } = useTour()
const step = useStep()
return (
<AnimatePresence mode="wait">
<motion.div
key={currentStepIndex}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
>
<h3>{step.title}</h3>
<p>{step.description}</p>
</motion.div>
</AnimatePresence>
)
}Respecting Reduced Motion
import { motion, useReducedMotion } from 'framer-motion'
function AccessibleAnimation({ children }) {
const shouldReduceMotion = useReducedMotion()
return (
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: shouldReduceMotion ? 0 : 0.3,
}}
>
{children}
</motion.div>
)
}Performance Tips
Use Transform and Opacity
These properties are GPU-accelerated:
/* Good - GPU accelerated */
.tour-card {
transform: scale(0.95);
opacity: 0;
transition: transform 200ms, opacity 200ms;
}
/* Avoid - causes layout reflow */
.tour-card {
width: 95%;
margin-top: 10px;
transition: width 200ms, margin 200ms;
}Avoid Layout Thrash
Don't read and write layout properties in rapid succession:
// Bad - causes layout thrash
function BadAnimation() {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ref.current) {
const height = ref.current.offsetHeight // Read
ref.current.style.height = `${height + 10}px` // Write
const width = ref.current.offsetWidth // Read again!
}
}, [])
}
// Good - batch reads, then writes
function GoodAnimation() {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ref.current) {
// Batch all reads
const height = ref.current.offsetHeight
const width = ref.current.offsetWidth
// Then all writes
requestAnimationFrame(() => {
ref.current!.style.height = `${height + 10}px`
ref.current!.style.width = `${width + 10}px`
})
}
}, [])
}Use will-change Sparingly
/* Only add will-change right before animation */
.tour-card-entering {
will-change: transform, opacity;
}
/* Remove after animation completes */
.tour-card-entered {
will-change: auto;
}Don't apply will-change to many elements at once. It increases memory usage and can actually hurt performance.
Announcement Animations
The @tour-kit/announcements package has its own animation system:
Modal Animations
/* Override default modal animation */
[data-tour-announcement-modal] {
animation: custom-modal-in 300ms ease-out;
}
@keyframes custom-modal-in {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}Toast Animations
[data-tour-announcement-toast] {
animation: toast-slide-in 200ms ease-out;
}
@keyframes toast-slide-in {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}Banner Animations
[data-tour-announcement-banner] {
animation: banner-reveal 300ms ease-out;
}
@keyframes banner-reveal {
from {
opacity: 0;
transform: translateY(-100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}Common Patterns
Staggered Entrance
Animate multiple elements with delay:
function StaggeredTourContent({ items }) {
return (
<div>
{items.map((item, index) => (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: index * 0.1,
duration: 0.2,
}}
>
{item.content}
</motion.div>
))}
</div>
)
}Exit Animations
Animate elements when tour closes:
import { useEffect, useState } from 'react'
import { useTour } from '@tour-kit/core'
function TourWithExitAnimation({ children }) {
const { isActive } = useTour()
const [shouldRender, setShouldRender] = useState(false)
const [isExiting, setIsExiting] = useState(false)
useEffect(() => {
if (isActive) {
setShouldRender(true)
setIsExiting(false)
} else if (shouldRender) {
setIsExiting(true)
// Wait for exit animation
const timer = setTimeout(() => {
setShouldRender(false)
setIsExiting(false)
}, 200)
return () => clearTimeout(timer)
}
}, [isActive, shouldRender])
if (!shouldRender) return null
return (
<div className={isExiting ? 'animate-fade-out' : 'animate-fade-in'}>
{children}
</div>
)
}Summary
| Need | Solution |
|---|---|
| Change duration | Override --tour-duration-* CSS variables |
| Custom keyframes | Add to Tailwind config or CSS |
| Disable animations | Set durations to 0ms |
| Reduced motion | Automatic via media query |
| Advanced animations | Integrate Framer Motion |
| Performance | Use transform/opacity, avoid layout properties |
Related Resources
Testing with React Testing Library
Test Tour Kit components with React Testing Library and Vitest under jsdom using @tour-kit/testing-library — zero consumer-side act() boilerplate.
Reduced Motion
How Tour Kit honors prefers-reduced-motion across announcements, surveys, hints, checklists, and the core tour runtime