From eeef3283c8594deccd597e155b4797a2a8793b9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sat, 28 Feb 2026 14:17:04 +0100 Subject: [PATCH] feat: add CSS Animation Editor tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/(app)/animate/page.tsx | 16 ++ components/AppIcons.tsx | 18 ++ components/animate/AnimationEditor.tsx | 104 +++++++++ components/animate/AnimationPreview.tsx | 166 ++++++++++++++ components/animate/AnimationSettings.tsx | 216 ++++++++++++++++++ components/animate/ExportPanel.tsx | 79 +++++++ components/animate/KeyframeProperties.tsx | 181 +++++++++++++++ components/animate/KeyframeTimeline.tsx | 146 ++++++++++++ components/animate/PresetLibrary.tsx | 104 +++++++++ lib/animate/cssBuilder.ts | 114 ++++++++++ lib/animate/defaults.ts | 47 ++++ lib/animate/presets.ts | 257 ++++++++++++++++++++++ lib/tools.tsx | 13 +- types/animate.ts | 64 ++++++ 14 files changed, 1524 insertions(+), 1 deletion(-) create mode 100644 app/(app)/animate/page.tsx create mode 100644 components/animate/AnimationEditor.tsx create mode 100644 components/animate/AnimationPreview.tsx create mode 100644 components/animate/AnimationSettings.tsx create mode 100644 components/animate/ExportPanel.tsx create mode 100644 components/animate/KeyframeProperties.tsx create mode 100644 components/animate/KeyframeTimeline.tsx create mode 100644 components/animate/PresetLibrary.tsx create mode 100644 lib/animate/cssBuilder.ts create mode 100644 lib/animate/defaults.ts create mode 100644 lib/animate/presets.ts create mode 100644 types/animate.ts diff --git a/app/(app)/animate/page.tsx b/app/(app)/animate/page.tsx new file mode 100644 index 0000000..19993e0 --- /dev/null +++ b/app/(app)/animate/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next'; +import { AnimationEditor } from '@/components/animate/AnimationEditor'; +import { AppPage } from '@/components/layout/AppPage'; +import { getToolByHref } from '@/lib/tools'; + +const tool = getToolByHref('/animate')!; + +export const metadata: Metadata = { title: tool.title, description: tool.summary }; + +export default function AnimatePage() { + return ( + + + + ); +} diff --git a/components/AppIcons.tsx b/components/AppIcons.tsx index acd7cb6..c88ba98 100644 --- a/components/AppIcons.tsx +++ b/components/AppIcons.tsx @@ -37,6 +37,24 @@ export const FaviconIcon = (props: React.SVGProps) => ( ); +export const AnimateIcon = (props: React.SVGProps) => ( + + + + + + + + + +); + export const QRCodeIcon = (props: React.SVGProps) => ( diff --git a/components/animate/AnimationEditor.tsx b/components/animate/AnimationEditor.tsx new file mode 100644 index 0000000..74be420 --- /dev/null +++ b/components/animate/AnimationEditor.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { AnimationSettings } from './AnimationSettings'; +import { AnimationPreview } from './AnimationPreview'; +import { KeyframeTimeline } from './KeyframeTimeline'; +import { KeyframeProperties } from './KeyframeProperties'; +import { PresetLibrary } from './PresetLibrary'; +import { ExportPanel } from './ExportPanel'; +import { DEFAULT_CONFIG, newKeyframe } from '@/lib/animate/defaults'; +import type { AnimationConfig, KeyframeProperties as KFProps, PreviewElement } from '@/types/animate'; + +export function AnimationEditor() { + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [selectedId, setSelectedId] = useState( + DEFAULT_CONFIG.keyframes[DEFAULT_CONFIG.keyframes.length - 1].id + ); + const [previewElement, setPreviewElement] = useState('box'); + + const selectedKeyframe = config.keyframes.find((k) => k.id === selectedId) ?? null; + + const updateKeyframeProps = useCallback((id: string, props: KFProps) => { + setConfig((c) => ({ + ...c, + keyframes: c.keyframes.map((k) => k.id === id ? { ...k, properties: props } : k), + })); + }, []); + + const addKeyframe = useCallback((offset: number) => { + const kf = newKeyframe(offset); + setConfig((c) => ({ ...c, keyframes: [...c.keyframes, kf] })); + setSelectedId(kf.id); + }, []); + + const deleteKeyframe = useCallback((id: string) => { + setConfig((c) => { + if (c.keyframes.length <= 2) return c; + const next = c.keyframes.filter((k) => k.id !== id); + return { ...c, keyframes: next }; + }); + setSelectedId((prev) => { + if (prev !== id) return prev; + const remaining = config.keyframes.filter((k) => k.id !== id); + return remaining[remaining.length - 1]?.id ?? null; + }); + }, [config.keyframes]); + + const moveKeyframe = useCallback((id: string, newOffset: number) => { + const clamped = Math.min(100, Math.max(0, Math.round(newOffset))); + setConfig((c) => ({ + ...c, + keyframes: c.keyframes.map((k) => k.id === id ? { ...k, offset: clamped } : k), + })); + }, []); + + const loadPreset = useCallback((presetConfig: AnimationConfig) => { + setConfig(presetConfig); + setSelectedId(presetConfig.keyframes[presetConfig.keyframes.length - 1].id); + }, []); + + return ( +
+ {/* Row 1: Settings + Preview */} +
+
+ +
+
+ +
+
+ + {/* Row 2: Keyframe Timeline */} + + + {/* Row 3: Keyframe Properties + Export */} +
+
+ +
+
+ +
+
+ + {/* Row 4: Preset Library */} + +
+ ); +} diff --git a/components/animate/AnimationPreview.tsx b/components/animate/AnimationPreview.tsx new file mode 100644 index 0000000..01ce403 --- /dev/null +++ b/components/animate/AnimationPreview.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { Play, Pause, RotateCcw, Square, Circle, Type } from 'lucide-react'; +import { buildCSS } from '@/lib/animate/cssBuilder'; +import type { AnimationConfig, PreviewElement } from '@/types/animate'; + +interface Props { + config: AnimationConfig; + element: PreviewElement; + onElementChange: (e: PreviewElement) => void; +} + +const SPEEDS: { label: string; value: string }[] = [ + { label: '0.25×', value: '0.25' }, + { label: '0.5×', value: '0.5' }, + { label: '1×', value: '1' }, + { label: '2×', value: '2' }, +]; + +export function AnimationPreview({ config, element, onElementChange }: Props) { + const styleRef = useRef(null); + const elementRef = useRef(null); + const [restartKey, setRestartKey] = useState(0); + const [paused, setPaused] = useState(false); + const [speed, setSpeed] = useState('1'); + + // Inject @keyframes CSS into document head + useEffect(() => { + if (!styleRef.current) { + styleRef.current = document.createElement('style'); + styleRef.current.id = 'kit-animate-preview'; + document.head.appendChild(styleRef.current); + } + styleRef.current.textContent = buildCSS(config); + }, [config]); + + // Cleanup on unmount + useEffect(() => { + return () => { + styleRef.current?.remove(); + }; + }, []); + + const restart = () => { + setPaused(false); + setRestartKey((k) => k + 1); + }; + + const scaledDuration = Math.round(config.duration / Number(speed)); + + return ( + + + Preview + + + + {/* Preview canvas */} +
+ {/* Grid overlay */} +
+ + {/* Animated element */} +
+ {element === 'box' && ( +
+ )} + {element === 'circle' && ( +
+ )} + {element === 'text' && ( + + Hello + + )} +
+
+ + {/* Controls */} +
+ v && onElementChange(v as PreviewElement)} + className="gap-1" + > + + + + + + + + + + + +
+ + + +
+
+ + + ); +} diff --git a/components/animate/AnimationSettings.tsx b/components/animate/AnimationSettings.tsx new file mode 100644 index 0000000..6ec20bd --- /dev/null +++ b/components/animate/AnimationSettings.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; +import { Infinity } from 'lucide-react'; +import type { AnimationConfig } from '@/types/animate'; + +interface Props { + config: AnimationConfig; + onChange: (config: AnimationConfig) => void; +} + +const EASINGS = [ + { value: 'linear', label: 'Linear' }, + { value: 'ease', label: 'Ease' }, + { value: 'ease-in', label: 'Ease In' }, + { value: 'ease-out', label: 'Ease Out' }, + { value: 'ease-in-out', label: 'Ease In Out' }, + { value: 'cubic-bezier', label: 'Cubic Bézier' }, + { value: 'steps(4, end)', label: 'Steps (4)' }, + { value: 'steps(8, end)', label: 'Steps (8)' }, +]; + +export function AnimationSettings({ config, onChange }: Props) { + const set = (key: K, value: AnimationConfig[K]) => + onChange({ ...config, [key]: value }); + + const isInfinite = config.iterationCount === 'infinite'; + const isCubic = config.easing === 'cubic-bezier'; + + // Parse cubic-bezier values from string like "cubic-bezier(x1,y1,x2,y2)" + const cubicValues = (() => { + const m = config.easing.match(/cubic-bezier\(([^)]+)\)/); + if (!m) return [0.25, 0.1, 0.25, 1.0]; + return m[1].split(',').map(Number); + })(); + + const setCubic = (index: number, val: number) => { + const v = [...cubicValues]; + v[index] = val; + set('easing', `cubic-bezier(${v.join(',')})`); + }; + + return ( + + + Settings + + + {/* Name */} +
+ + { + const val = e.target.value.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-_]/g, ''); + set('name', val || 'myAnimation'); + }} + className="font-mono text-xs" + /> +
+ + {/* Duration + Delay */} +
+
+ +
+ set('duration', Math.max(50, Number(e.target.value)))} + className="text-xs" + /> + ms +
+
+
+ +
+ set('delay', Math.max(0, Number(e.target.value)))} + className="text-xs" + /> + ms +
+
+
+ + {/* Easing */} +
+ + +
+ + {/* Cubic-bezier inputs */} + {isCubic && ( +
+ +
+ {(['P1x', 'P1y', 'P2x', 'P2y'] as const).map((label, i) => ( +
+ + setCubic(i, Number(e.target.value))} + className="text-xs px-1.5" + /> +
+ ))} +
+
+ )} + + {/* Iteration */} +
+ +
+ set('iterationCount', Math.max(1, Number(e.target.value)))} + className="text-xs flex-1" + placeholder="1" + /> + +
+
+ + {/* Direction */} +
+ + +
+ + {/* Fill Mode */} +
+ + +
+
+
+ ); +} diff --git a/components/animate/ExportPanel.tsx b/components/animate/ExportPanel.tsx new file mode 100644 index 0000000..c8e7f4e --- /dev/null +++ b/components/animate/ExportPanel.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useMemo } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Button } from '@/components/ui/button'; +import { Copy, Download } from 'lucide-react'; +import { toast } from 'sonner'; +import { buildCSS, buildTailwindCSS } from '@/lib/animate/cssBuilder'; +import type { AnimationConfig } from '@/types/animate'; + +interface Props { + config: AnimationConfig; +} + +function CodeBlock({ code, filename }: { code: string; filename: string }) { + const copy = () => { + navigator.clipboard.writeText(code); + toast.success('Copied to clipboard!'); + }; + + const download = () => { + const blob = new Blob([code], { type: 'text/css' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + toast.success(`Downloaded ${filename}`); + }; + + return ( +
+
+
+          {code}
+        
+
+
+ + +
+
+ ); +} + +export function ExportPanel({ config }: Props) { + const css = useMemo(() => buildCSS(config), [config]); + const tailwind = useMemo(() => buildTailwindCSS(config), [config]); + + return ( + + + Export + + + + + Plain CSS + Tailwind v4 + + + + + + + + + + + ); +} diff --git a/components/animate/KeyframeProperties.tsx b/components/animate/KeyframeProperties.tsx new file mode 100644 index 0000000..c74eb23 --- /dev/null +++ b/components/animate/KeyframeProperties.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Slider } from '@/components/ui/slider'; +import { Button } from '@/components/ui/button'; +import { MousePointerClick } from 'lucide-react'; +import { cn } from '@/lib/utils/cn'; +import type { Keyframe, KeyframeProperties, TransformValue } from '@/types/animate'; +import { DEFAULT_TRANSFORM } from '@/lib/animate/defaults'; + +interface Props { + keyframe: Keyframe | null; + onChange: (id: string, props: KeyframeProperties) => void; +} + +interface SliderRowProps { + label: string; + unit?: string; + value: number; + min: number; + max: number; + step?: number; + onChange: (v: number) => void; +} + +function SliderRow({ label, unit, value, min, max, step = 1, onChange }: SliderRowProps) { + return ( +
+
+ + onChange(v)} + /> +
+
+ onChange(Number(e.target.value))} + className="w-16 text-xs px-1.5 h-7" + /> + {unit && {unit}} +
+
+ ); +} + +export function KeyframeProperties({ keyframe, onChange }: Props) { + if (!keyframe) { + return ( + + + Properties + + + +

Select a keyframe on the timeline to edit its properties

+
+
+ ); + } + + const props = keyframe.properties; + const t: TransformValue = { ...DEFAULT_TRANSFORM, ...props.transform }; + + const setTransform = (key: keyof TransformValue, value: number) => { + onChange(keyframe.id, { + ...props, + transform: { ...t, [key]: value }, + }); + }; + + const setProp = (key: K, value: KeyframeProperties[K]) => { + onChange(keyframe.id, { ...props, [key]: value }); + }; + + const hasBg = props.backgroundColor && props.backgroundColor !== 'none'; + + return ( + + + + Properties + {keyframe.offset}% + + + + + {/* Transform */} +
+

Transform

+ setTransform('translateX', v)} /> + setTransform('translateY', v)} /> + setTransform('rotate', v)} /> + setTransform('scaleX', v)} /> + setTransform('scaleY', v)} /> + setTransform('skewX', v)} /> + setTransform('skewY', v)} /> +
+ + {/* Visual */} +
+

Visual

+ + setProp('opacity', v)} + /> + + {/* Background color */} +
+ +
+ setProp('backgroundColor', e.target.value)} + disabled={!hasBg} + className={cn('w-9 h-9 p-1 shrink-0 cursor-pointer', !hasBg && 'opacity-30')} + /> + setProp('backgroundColor', e.target.value)} + disabled={!hasBg} + placeholder="none" + className="font-mono text-xs flex-1" + /> + +
+
+ + setProp('borderRadius', v)} + /> +
+ + {/* Filters */} +
+

Filter

+ setProp('blur', v)} + /> + setProp('brightness', v)} + /> +
+ +
+
+ ); +} diff --git a/components/animate/KeyframeTimeline.tsx b/components/animate/KeyframeTimeline.tsx new file mode 100644 index 0000000..d5a413f --- /dev/null +++ b/components/animate/KeyframeTimeline.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useRef } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Plus, Trash2 } from 'lucide-react'; +import { cn } from '@/lib/utils/cn'; +import type { Keyframe } from '@/types/animate'; + +interface Props { + keyframes: Keyframe[]; + selectedId: string | null; + onSelect: (id: string) => void; + onAdd: (offset: number) => void; + onDelete: (id: string) => void; + onMove: (id: string, newOffset: number) => void; +} + +const TICKS = [0, 25, 50, 75, 100]; + +export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDelete, onMove }: Props) { + const trackRef = useRef(null); + + const getOffsetFromEvent = (clientX: number): number => { + if (!trackRef.current) return 0; + const rect = trackRef.current.getBoundingClientRect(); + const pct = ((clientX - rect.left) / rect.width) * 100; + return Math.round(Math.min(100, Math.max(0, pct))); + }; + + const handleTrackClick = (e: React.MouseEvent) => { + // Ignore clicks that land directly on a keyframe marker + if ((e.target as HTMLElement).closest('[data-keyframe-marker]')) return; + onAdd(getOffsetFromEvent(e.clientX)); + }; + + const handlePointerDown = (e: React.PointerEvent, id: string) => { + e.preventDefault(); + onSelect(id); + const el = e.currentTarget as HTMLElement; + el.setPointerCapture(e.pointerId); + + const handleMove = (me: PointerEvent) => { + onMove(id, getOffsetFromEvent(me.clientX)); + }; + + const handleUp = () => { + el.removeEventListener('pointermove', handleMove); + el.removeEventListener('pointerup', handleUp); + }; + + el.addEventListener('pointermove', handleMove); + el.addEventListener('pointerup', handleUp); + }; + + const sorted = [...keyframes].sort((a, b) => a.offset - b.offset); + const selectedKf = keyframes.find((k) => k.id === selectedId); + + return ( + + + Keyframes +
+ + {keyframes.length} keyframe{keyframes.length !== 1 ? 's' : ''} + {selectedKf ? ` · selected: ${selectedKf.offset}%` : ''} + + + +
+
+ + {/* Track */} +
+ {/* Center line */} +
+ + {/* Tick marks */} + {TICKS.map((tick) => ( +
+
+ {tick}% +
+ ))} + + {/* Keyframe markers */} + {sorted.map((kf) => ( +
+ + {/* Offset labels below */} +
+ {sorted.map((kf) => ( + + {kf.offset}% + + ))} +
+ + + ); +} diff --git a/components/animate/PresetLibrary.tsx b/components/animate/PresetLibrary.tsx new file mode 100644 index 0000000..046ff0f --- /dev/null +++ b/components/animate/PresetLibrary.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { cn } from '@/lib/utils/cn'; +import { PRESETS, PRESET_CATEGORIES } from '@/lib/animate/presets'; +import { buildCSS } from '@/lib/animate/cssBuilder'; +import type { AnimationConfig, AnimationPreset } from '@/types/animate'; + +interface Props { + currentName: string; + onSelect: (config: AnimationConfig) => void; +} + +function PresetCard({ preset, isActive, onSelect }: { + preset: AnimationPreset; + isActive: boolean; + onSelect: () => void; +}) { + const styleRef = useRef(null); + const key = `preset-${preset.id}`; + + // Each card gets its own @keyframes injected with a unique name to avoid conflicts + useEffect(() => { + const renamedConfig = { + ...preset.config, + name: key, + keyframes: preset.config.keyframes, + }; + if (!styleRef.current) { + styleRef.current = document.createElement('style'); + document.head.appendChild(styleRef.current); + } + styleRef.current.textContent = buildCSS(renamedConfig).replace( + /animation: \S+/, + `animation: ${key}` + ); + return () => { + styleRef.current?.remove(); + styleRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + ); +} + +export function PresetLibrary({ currentName, onSelect }: Props) { + return ( + + + Presets + + + + + {PRESET_CATEGORIES.map((cat) => ( + + {cat} + + ))} + + {PRESET_CATEGORIES.map((cat) => ( + +
+ {PRESETS.filter((p) => p.category === cat).map((preset) => ( + onSelect(preset.config)} + /> + ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/lib/animate/cssBuilder.ts b/lib/animate/cssBuilder.ts new file mode 100644 index 0000000..cf7da5d --- /dev/null +++ b/lib/animate/cssBuilder.ts @@ -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; +} diff --git a/lib/animate/defaults.ts b/lib/animate/defaults.ts new file mode 100644 index 0000000..95a5382 --- /dev/null +++ b/lib/animate/defaults.ts @@ -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 }, + }, + }, + ], +}; diff --git a/lib/animate/presets.ts b/lib/animate/presets.ts new file mode 100644 index 0000000..5be09e0 --- /dev/null +++ b/lib/animate/presets.ts @@ -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, +): 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); +} diff --git a/lib/tools.tsx b/lib/tools.tsx index c24450f..6697456 100644 --- a/lib/tools.tsx +++ b/lib/tools.tsx @@ -1,4 +1,4 @@ -import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon, QRCodeIcon } from '@/components/AppIcons'; +import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon, QRCodeIcon, AnimateIcon } from '@/components/AppIcons'; export interface Tool { /** Short display name (e.g. "Color") */ @@ -86,6 +86,17 @@ export const tools: Tool[] = [ icon: QRCodeIcon, badges: ['Open Source', 'Generator', 'Free'], }, + { + shortTitle: 'Animate', + title: 'CSS Animation Editor', + navTitle: 'CSS Animations', + href: '/animate', + description: 'Visual editor for CSS keyframe animations with live preview.', + summary: + 'Build and export CSS @keyframe animations visually. Configure timing, easing, transforms, and more — with a live preview and 20+ built-in presets. Export to plain CSS or Tailwind v4.', + icon: AnimateIcon, + badges: ['Open Source', 'CSS', 'Free'], + }, ]; /** Look up a tool by its href path */ diff --git a/types/animate.ts b/types/animate.ts new file mode 100644 index 0000000..ac3ee14 --- /dev/null +++ b/types/animate.ts @@ -0,0 +1,64 @@ +export type EasingPreset = + | 'linear' + | 'ease' + | 'ease-in' + | 'ease-out' + | 'ease-in-out' + | 'cubic-bezier' + | 'steps'; + +export type AnimationDirection = + | 'normal' + | 'reverse' + | 'alternate' + | 'alternate-reverse'; + +export type AnimationFillMode = 'none' | 'forwards' | 'backwards' | 'both'; + +export type PreviewElement = 'box' | 'circle' | 'text'; + +export type PresetCategory = 'Entrance' | 'Exit' | 'Attention' | 'Special'; + +export interface TransformValue { + translateX: number; // px + translateY: number; // px + rotate: number; // deg + scaleX: number; // ratio (1 = no scale) + scaleY: number; // ratio + skewX: number; // deg + skewY: number; // deg +} + +export interface KeyframeProperties { + transform?: TransformValue; + opacity?: number; // 0–1 + backgroundColor?: string; // hex or 'none' + borderRadius?: number; // px + blur?: number; // px + brightness?: number; // ratio (1 = no change) +} + +export interface Keyframe { + id: string; + offset: number; // 0–100 + properties: KeyframeProperties; + easing?: string; // per-keyframe easing to next +} + +export interface AnimationConfig { + name: string; + duration: number; // ms + delay: number; // ms + easing: string; // CSS easing string + iterationCount: number | 'infinite'; + direction: AnimationDirection; + fillMode: AnimationFillMode; + keyframes: Keyframe[]; +} + +export interface AnimationPreset { + id: string; + name: string; + category: PresetCategory; + config: AnimationConfig; +}