feat: add CSS Animation Editor tool
Comprehensive visual editor for CSS @keyframe animations: - AnimationSettings: name, duration, delay, easing (incl. cubic-bezier), iteration, direction, fill-mode - KeyframeTimeline: drag-to-reposition keyframe markers, click-track to add, delete selected - KeyframeProperties: per-keyframe transform (translate/rotate/scale/skew), opacity, background-color, border-radius, blur, brightness via sliders - AnimationPreview: live preview on box/circle/text element with play/pause/restart and speed control (0.25×–2×) - PresetLibrary: 22 presets across Entrance/Exit/Attention/Special categories with animated thumbnails - ExportPanel: plain CSS and Tailwind v4 @utility formats with copy and download Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
114
lib/animate/cssBuilder.ts
Normal file
114
lib/animate/cssBuilder.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { AnimationConfig, Keyframe, KeyframeProperties, TransformValue } from '@/types/animate';
|
||||
import { DEFAULT_TRANSFORM } from './defaults';
|
||||
|
||||
function isIdentityTransform(t: TransformValue): boolean {
|
||||
return (
|
||||
t.translateX === 0 &&
|
||||
t.translateY === 0 &&
|
||||
t.rotate === 0 &&
|
||||
t.scaleX === 1 &&
|
||||
t.scaleY === 1 &&
|
||||
t.skewX === 0 &&
|
||||
t.skewY === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function buildTransform(t: TransformValue): string {
|
||||
if (isIdentityTransform(t)) return '';
|
||||
const parts: string[] = [];
|
||||
if (t.translateX !== 0 || t.translateY !== 0)
|
||||
parts.push(`translate(${t.translateX}px, ${t.translateY}px)`);
|
||||
if (t.rotate !== 0) parts.push(`rotate(${t.rotate}deg)`);
|
||||
if (t.scaleX !== 1 || t.scaleY !== 1) {
|
||||
parts.push(t.scaleX === t.scaleY ? `scale(${t.scaleX})` : `scale(${t.scaleX}, ${t.scaleY})`);
|
||||
}
|
||||
if (t.skewX !== 0 || t.skewY !== 0)
|
||||
parts.push(`skew(${t.skewX}deg, ${t.skewY}deg)`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function buildProperties(props: KeyframeProperties): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (props.transform) {
|
||||
const t = { ...DEFAULT_TRANSFORM, ...props.transform };
|
||||
const val = buildTransform(t);
|
||||
lines.push(`transform: ${val || 'none'}`);
|
||||
}
|
||||
|
||||
if (props.opacity !== undefined) lines.push(`opacity: ${props.opacity}`);
|
||||
if (props.backgroundColor && props.backgroundColor !== 'none')
|
||||
lines.push(`background-color: ${props.backgroundColor}`);
|
||||
if (props.borderRadius !== undefined && props.borderRadius !== 0)
|
||||
lines.push(`border-radius: ${props.borderRadius}px`);
|
||||
|
||||
const filterParts: string[] = [];
|
||||
if (props.blur !== undefined && props.blur !== 0) filterParts.push(`blur(${props.blur}px)`);
|
||||
if (props.brightness !== undefined && props.brightness !== 1)
|
||||
filterParts.push(`brightness(${props.brightness})`);
|
||||
if (filterParts.length) lines.push(`filter: ${filterParts.join(' ')}`);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildIterationCount(count: number | 'infinite'): string {
|
||||
return count === 'infinite' ? 'infinite' : String(count);
|
||||
}
|
||||
|
||||
export function buildAnimationShorthand(config: AnimationConfig): string {
|
||||
const iter = buildIterationCount(config.iterationCount);
|
||||
const delay = config.delay ? ` ${config.delay}ms` : '';
|
||||
return `${config.name} ${config.duration}ms ${config.easing}${delay} ${iter} ${config.direction} ${config.fillMode}`;
|
||||
}
|
||||
|
||||
export function buildCSS(config: AnimationConfig): string {
|
||||
const sorted = [...config.keyframes].sort((a, b) => a.offset - b.offset);
|
||||
|
||||
let out = `@keyframes ${config.name} {\n`;
|
||||
for (const kf of sorted) {
|
||||
const lines = buildProperties(kf.properties);
|
||||
if (lines.length === 0) {
|
||||
out += ` ${kf.offset}% { }\n`;
|
||||
} else {
|
||||
out += ` ${kf.offset}% {\n`;
|
||||
for (const line of lines) out += ` ${line};\n`;
|
||||
if (kf.easing) out += ` animation-timing-function: ${kf.easing};\n`;
|
||||
out += ` }\n`;
|
||||
}
|
||||
}
|
||||
out += `}\n\n`;
|
||||
|
||||
out += `.animated {\n`;
|
||||
out += ` animation: ${buildAnimationShorthand(config)};\n`;
|
||||
out += `}\n\n`;
|
||||
|
||||
out += `/* Usage: add class="animated" to your element */`;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildTailwindCSS(config: AnimationConfig): string {
|
||||
const sorted = [...config.keyframes].sort((a, b) => a.offset - b.offset);
|
||||
|
||||
let out = `/* In your globals.css */\n\n`;
|
||||
|
||||
out += `@keyframes ${config.name} {\n`;
|
||||
for (const kf of sorted) {
|
||||
const lines = buildProperties(kf.properties);
|
||||
if (lines.length === 0) {
|
||||
out += ` ${kf.offset}% { }\n`;
|
||||
} else {
|
||||
out += ` ${kf.offset}% {\n`;
|
||||
for (const line of lines) out += ` ${line};\n`;
|
||||
if (kf.easing) out += ` animation-timing-function: ${kf.easing};\n`;
|
||||
out += ` }\n`;
|
||||
}
|
||||
}
|
||||
out += `}\n\n`;
|
||||
|
||||
out += `@utility animate-${config.name} {\n`;
|
||||
out += ` animation: ${buildAnimationShorthand(config)};\n`;
|
||||
out += `}\n\n`;
|
||||
|
||||
out += `/* Usage: className="animate-${config.name}" */`;
|
||||
return out;
|
||||
}
|
||||
47
lib/animate/defaults.ts
Normal file
47
lib/animate/defaults.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { AnimationConfig, Keyframe, TransformValue } from '@/types/animate';
|
||||
|
||||
export const DEFAULT_TRANSFORM: TransformValue = {
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
rotate: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
skewX: 0,
|
||||
skewY: 0,
|
||||
};
|
||||
|
||||
export function newKeyframe(offset: number): Keyframe {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
offset,
|
||||
properties: {},
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: AnimationConfig = {
|
||||
name: 'fadeInUp',
|
||||
duration: 600,
|
||||
delay: 0,
|
||||
easing: 'ease-out',
|
||||
iterationCount: 1,
|
||||
direction: 'normal',
|
||||
fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
offset: 0,
|
||||
properties: {
|
||||
opacity: 0,
|
||||
transform: { ...DEFAULT_TRANSFORM, translateY: 20 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
offset: 100,
|
||||
properties: {
|
||||
opacity: 1,
|
||||
transform: { ...DEFAULT_TRANSFORM },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
257
lib/animate/presets.ts
Normal file
257
lib/animate/presets.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { AnimationPreset, AnimationConfig } from '@/types/animate';
|
||||
import { DEFAULT_TRANSFORM } from './defaults';
|
||||
|
||||
function preset(
|
||||
id: string,
|
||||
name: string,
|
||||
category: AnimationPreset['category'],
|
||||
config: Omit<AnimationConfig, 'name'>,
|
||||
): AnimationPreset {
|
||||
return { id, name, category, config: { ...config, name: id } };
|
||||
}
|
||||
|
||||
const T = DEFAULT_TRANSFORM;
|
||||
|
||||
export const PRESETS: AnimationPreset[] = [
|
||||
// ─── Entrance ────────────────────────────────────────────────────────────────
|
||||
|
||||
preset('fadeIn', 'Fade In', 'Entrance', {
|
||||
duration: 500, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 0 } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 1 } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('fadeInUp', 'Fade In Up', 'Entrance', {
|
||||
duration: 600, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, translateY: 30 } } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('fadeInDown', 'Fade In Down', 'Entrance', {
|
||||
duration: 600, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, translateY: -30 } } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('fadeInLeft', 'Fade In Left', 'Entrance', {
|
||||
duration: 600, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, translateX: -40 } } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('fadeInRight', 'Fade In Right', 'Entrance', {
|
||||
duration: 600, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, translateX: 40 } } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('zoomIn', 'Zoom In', 'Entrance', {
|
||||
duration: 400, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, scaleX: 0.5, scaleY: 0.5 } } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('bounceIn', 'Bounce In', 'Entrance', {
|
||||
duration: 750, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, scaleX: 0.3, scaleY: 0.3 } } },
|
||||
{ id: 'b', offset: 50, properties: { opacity: 1, transform: { ...T, scaleX: 1.1, scaleY: 1.1 } } },
|
||||
{ id: 'c', offset: 75, properties: { transform: { ...T, scaleX: 0.9, scaleY: 0.9 } } },
|
||||
{ id: 'd', offset: 100, properties: { opacity: 1, transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('slideInLeft', 'Slide In Left', 'Entrance', {
|
||||
duration: 500, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T, translateX: -100 } } },
|
||||
{ id: 'b', offset: 100, properties: { transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('rotateIn', 'Rotate In', 'Entrance', {
|
||||
duration: 600, delay: 0, easing: 'ease-out',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, rotate: -180, scaleX: 0.6, scaleY: 0.6 } } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
// ─── Exit ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
preset('fadeOut', 'Fade Out', 'Exit', {
|
||||
duration: 500, delay: 0, easing: 'ease-in',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 1 } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 0 } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('fadeOutDown', 'Fade Out Down', 'Exit', {
|
||||
duration: 600, delay: 0, easing: 'ease-in',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 1, transform: { ...T } } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 0, transform: { ...T, translateY: 30 } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('zoomOut', 'Zoom Out', 'Exit', {
|
||||
duration: 400, delay: 0, easing: 'ease-in',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 1, transform: { ...T } } },
|
||||
{ id: 'b', offset: 100, properties: { opacity: 0, transform: { ...T, scaleX: 0.4, scaleY: 0.4 } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('slideOutRight', 'Slide Out Right', 'Exit', {
|
||||
duration: 500, delay: 0, easing: 'ease-in',
|
||||
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
|
||||
{ id: 'b', offset: 100, properties: { transform: { ...T, translateX: 100 } } },
|
||||
],
|
||||
}),
|
||||
|
||||
// ─── Attention ────────────────────────────────────────────────────────────────
|
||||
|
||||
preset('pulse', 'Pulse', 'Attention', {
|
||||
duration: 1000, delay: 0, easing: 'ease-in-out',
|
||||
iterationCount: 'infinite', direction: 'alternate', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
|
||||
{ id: 'b', offset: 100, properties: { transform: { ...T, scaleX: 1.08, scaleY: 1.08 } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('shake', 'Shake', 'Attention', {
|
||||
duration: 600, delay: 0, easing: 'ease-in-out',
|
||||
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
|
||||
{ id: 'b', offset: 20, properties: { transform: { ...T, translateX: -8 } } },
|
||||
{ id: 'c', offset: 40, properties: { transform: { ...T, translateX: 8 } } },
|
||||
{ id: 'd', offset: 60, properties: { transform: { ...T, translateX: -6 } } },
|
||||
{ id: 'e', offset: 80, properties: { transform: { ...T, translateX: 6 } } },
|
||||
{ id: 'f', offset: 100, properties: { transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('wobble', 'Wobble', 'Attention', {
|
||||
duration: 800, delay: 0, easing: 'ease-in-out',
|
||||
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
|
||||
{ id: 'b', offset: 20, properties: { transform: { ...T, translateX: -10, rotate: -5 } } },
|
||||
{ id: 'c', offset: 50, properties: { transform: { ...T, translateX: 8, rotate: 4 } } },
|
||||
{ id: 'd', offset: 80, properties: { transform: { ...T, translateX: -5, rotate: -3 } } },
|
||||
{ id: 'e', offset: 100, properties: { transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('swing', 'Swing', 'Attention', {
|
||||
duration: 1000, delay: 0, easing: 'ease-in-out',
|
||||
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
|
||||
{ id: 'b', offset: 25, properties: { transform: { ...T, rotate: 15 } } },
|
||||
{ id: 'c', offset: 50, properties: { transform: { ...T, rotate: -12 } } },
|
||||
{ id: 'd', offset: 75, properties: { transform: { ...T, rotate: 8 } } },
|
||||
{ id: 'e', offset: 100, properties: { transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('flash', 'Flash', 'Attention', {
|
||||
duration: 800, delay: 0, easing: 'ease-in-out',
|
||||
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { opacity: 1 } },
|
||||
{ id: 'b', offset: 25, properties: { opacity: 0 } },
|
||||
{ id: 'c', offset: 50, properties: { opacity: 1 } },
|
||||
{ id: 'd', offset: 75, properties: { opacity: 0 } },
|
||||
{ id: 'e', offset: 100, properties: { opacity: 1 } },
|
||||
],
|
||||
}),
|
||||
|
||||
// ─── Special ──────────────────────────────────────────────────────────────────
|
||||
|
||||
preset('spin', 'Spin', 'Special', {
|
||||
duration: 1000, delay: 0, easing: 'linear',
|
||||
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T, rotate: 0 } } },
|
||||
{ id: 'b', offset: 100, properties: { transform: { ...T, rotate: 360 } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('ping', 'Ping', 'Special', {
|
||||
duration: 1200, delay: 0, easing: 'cubic-bezier(0, 0, 0.2, 1)',
|
||||
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T }, opacity: 1 } },
|
||||
{ id: 'b', offset: 75, properties: { transform: { ...T, scaleX: 2, scaleY: 2 }, opacity: 0 } },
|
||||
{ id: 'c', offset: 100, properties: { transform: { ...T, scaleX: 2, scaleY: 2 }, opacity: 0 } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('wave', 'Wave', 'Special', {
|
||||
duration: 1500, delay: 0, easing: 'ease-in-out',
|
||||
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T, rotate: 0 } } },
|
||||
{ id: 'b', offset: 15, properties: { transform: { ...T, rotate: 14 } } },
|
||||
{ id: 'c', offset: 30, properties: { transform: { ...T, rotate: -8 } } },
|
||||
{ id: 'd', offset: 40, properties: { transform: { ...T, rotate: 14 } } },
|
||||
{ id: 'e', offset: 50, properties: { transform: { ...T, rotate: -4 } } },
|
||||
{ id: 'f', offset: 60, properties: { transform: { ...T, rotate: 10 } } },
|
||||
{ id: 'g', offset: 100, properties: { transform: { ...T, rotate: 0 } } },
|
||||
],
|
||||
}),
|
||||
|
||||
preset('heartbeat', 'Heartbeat', 'Special', {
|
||||
duration: 1300, delay: 0, easing: 'ease-in-out',
|
||||
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
|
||||
keyframes: [
|
||||
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
|
||||
{ id: 'b', offset: 14, properties: { transform: { ...T, scaleX: 1.3, scaleY: 1.3 } } },
|
||||
{ id: 'c', offset: 28, properties: { transform: { ...T } } },
|
||||
{ id: 'd', offset: 42, properties: { transform: { ...T, scaleX: 1.3, scaleY: 1.3 } } },
|
||||
{ id: 'e', offset: 70, properties: { transform: { ...T } } },
|
||||
{ id: 'f', offset: 100, properties: { transform: { ...T } } },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
export const PRESET_CATEGORIES: AnimationPreset['category'][] = [
|
||||
'Entrance',
|
||||
'Exit',
|
||||
'Attention',
|
||||
'Special',
|
||||
];
|
||||
|
||||
export function getPresetsByCategory(category: AnimationPreset['category']): AnimationPreset[] {
|
||||
return PRESETS.filter((p) => p.category === category);
|
||||
}
|
||||
Reference in New Issue
Block a user