Animation
Add smooth animations and transitions to blakeUI components
blakeUI components support multiple animation approaches: built-in CSS transitions, custom CSS animations, and JavaScript libraries like Framer Motion.
Built-in Animations
blakeUI components use data attributes to expose their state for animation:
/* Popover entrance/exit */
.popover[data-entering] {
@apply animate-in zoom-in-90 fade-in-0 duration-200;
}
.popover[data-exiting] {
@apply animate-out zoom-out-95 fade-out duration-150;
}
/* Button press effect */
.button:active,
.button[data-pressed="true"] {
transform: scale(0.97);
}
/* Accordion expansion */
.accordion__panel[aria-hidden="false"] {
@apply h-[var(--panel-height)] opacity-100;
}State attributes for styling:
[data-hovered="true"]- Hover state[data-pressed="true"]- Active/pressed state[data-focus-visible="true"]- Keyboard focus[data-disabled="true"]- Disabled state[data-entering]/[data-exiting]- Transition states[aria-expanded="true"]- Expanded state
CSS Animations
Using Tailwind utilities:
// Pulse on hover
<Button className="hover:animate-pulse">
Hover me
</Button>
// Fade in entrance
<Alert className="animate-fade-in">
Welcome message
</Alert>
// Staggered list
<div className="space-y-2">
<Card className="animate-fade-in animate-delay-100">Item 1</Card>
<Card className="animate-fade-in animate-delay-200">Item 2</Card>
</div>Custom transitions:
/* Slower accordion */
.accordion__panel {
@apply transition-all duration-500;
}
/* Bouncy button */
.button:active {
animation: bounce 0.3s;
}
@keyframes bounce {
50% { transform: scale(0.95); }
}Framer Motion
blakeUI components work seamlessly with Framer Motion for advanced animations.
Basic usage:
import { motion } from 'framer-motion';
import { Button } from '@blakeui/react';
const MotionButton = motion(Button);
<MotionButton
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Animated Button
</MotionButton>Entrance animations:
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Alert>
<Alert.Title>Welcome!</Alert.Title>
</Alert>
</motion.div>Layout animations:
import { AnimatePresence, motion } from 'framer-motion';
function Tabs({ items, selected }) {
return (
<div className="flex gap-2">
{items.map((item, i) => (
<Button key={i} onPress={() => setSelected(i)}>
{item}
{selected === i && (
<motion.div
layoutId="active"
className="absolute inset-0 bg-accent"
transition={{ type: "spring", bounce: 0.2 }}
/>
)}
</Button>
))}
</div>
);
}Render Props
Apply dynamic animations based on component state:
<Button>
{({ isPressed, isHovered }) => (
<motion.span
animate={{
scale: isPressed ? 0.95 : isHovered ? 1.05 : 1
}}
>
Interactive Button
</motion.span>
)}
</Button>Accessibility
Respecting motion preferences: blakeUI respects user motion preferences using Tailwind's motion-reduce: utility. Reduced motion is gentler, not zero: movement is suppressed, while colour and opacity transitions keep running. A button under reduced motion still tints on hover, it just stops shrinking on press.
blakeUI extends Tailwind's motion-reduce: variant to support both the native prefers-reduced-motion media query and the data-reduce-motion attribute.
Do not reach for motion-reduce:transition-none unless the declaration transitions only movement. On a mixed declaration it removes the colour transition too, which is the opposite of what reduced motion asks for.
/* Movement only - transition-none suppresses exactly the movement */
.chevron {
transition: transform 150ms var(--ease-out);
@apply motion-reduce:transition-none;
}
/* Mixed - route the movement through a custom property instead, so
colour keeps its own timing and `transform` stays alone in its block */
.button {
transition:
transform 250ms var(--ease-smooth),
background-color 100ms var(--ease-out);
--button-press-scale: 0.97;
@apply motion-reduce:[--button-press-scale:1];
&:active {
transform: scale(var(--button-press-scale));
}
}
/* Blanket `transition` / `transition-all` covers transform, translate,
scale and rotate - narrow the list rather than dropping it */
.indicator {
@apply transition duration-250 motion-reduce:transition-[color,background-color,opacity];
}Keep transform and scale in separate declaration blocks. Lightning CSS folds a sibling scale into transform, which silently neutralises any opt-out that targets scale.
The same rule applies to keyframe animations. motion-reduce:animate-none removes the fade along with the movement, and stops the animationend that React Aria waits on before unmounting an exiting overlay. Use animate-flat instead - it neutralises the enter/exit transform variables so the animation keeps running as a pure opacity fade:
.popover {
&[data-entering="true"] {
@apply animate-in duration-150 fade-in zoom-in-95 slide-in-from-top-1;
}
/* Still fades; no longer zooms or slides */
@apply motion-reduce:animate-flat;
}For a hand-written keyframe, route its transform through custom properties and neutralise those instead, so both ends of the keyframe collapse to the same transform.
Continuous indicators need a different answer again. A spinner or indeterminate bar has no gentler version of its own motion, and freezing it mid-cycle reads as stalled rather than calm. Swap the looping motion for an opacity pulse so the component still says "working":
.progress-circle__track {
animation: progress-circle-spin 1s linear infinite;
/* Stops spinning, keeps signalling activity */
@apply motion-reduce:animate-pulse;
}If the resting geometry would misread, correct that too. The indeterminate bar is w-2/5 while travelling, so it also goes motion-reduce:w-full - otherwise it freezes looking like a stalled 40%.
With Framer Motion:
import { useReducedMotion } from 'framer-motion';
function AnimatedCard() {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.5 }}
>
<Card>Content</Card>
</motion.div>
);
}Disabling animations globally: Add data-reduce-motion="true" to the <html> or <body> tag:
<html data-reduce-motion="true">
<!-- Movement is suppressed; colour and opacity transitions still run -->
</html>blakeUI automatically detects the user's prefers-reduced-motion: reduce setting; the attribute overrides it in either direction.
Performance Tips
Use GPU-accelerated properties: Prefer transform and opacity for smooth animations:
/* Good - GPU accelerated */
.slide-in {
transform: translateX(-100%);
transition: transform 0.3s;
}
/* Avoid - Triggers layout */
.slide-in {
left: -100%;
transition: left 0.3s;
}Will-change optimization: Use will-change to optimize animations, but remove it when not animating:
.button {
will-change: transform;
}
.button:not(:hover) {
will-change: auto;
}