'use client'; import { type Frame, type Frames, MotionGrid, } from '@/registry/components/motion-grid'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/registry/components/tooltip'; import { Trash2Icon } from '@/registry/icons/trash-2'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/registry/radix/dropdown-menu'; import { Button } from '@workspace/ui/components/ui/button'; import { Input } from '@workspace/ui/components/ui/input'; import { Label } from '@workspace/ui/components/ui/label'; import { ScrollArea } from '@workspace/ui/components/ui/scroll-area'; import { cn } from '@workspace/ui/lib/utils'; import { ArrowLeftIcon, ArrowRightIcon, CopyIcon, PlusIcon, RotateCcwIcon, SaveIcon, CheckIcon, XIcon, Timer, SquareRoundCorner, } from 'lucide-react'; import * as React from 'react'; const GRID_SIZE = [7, 7] as [number, number]; const GRID_SIZE_MAX = 20; const GRID_SIZE_MIN = 4; const DEFAULT_DURATION = '200'; const DEFAULT_BORDER_RADIUS = '100'; const DEFAULT_BORDER_RADIUS_UNIT = '%'; const BORDER_RADIUS_UNITS = ['px', 'rem', 'em', '%']; const formatGridSizeNumber = (value: number) => { if (value < GRID_SIZE_MIN) return GRID_SIZE_MIN; if (value > GRID_SIZE_MAX) return GRID_SIZE_MAX; return Math.round(value); }; const MyAnimation = ({ name, value, selectAnimation, deleteAnimation, active, }: { name: string; value: { gridSize: [number, number]; frames: Frames; duration: string; borderRadius: string; borderRadiusUnit: string; }; selectAnimation: () => void; deleteAnimation: () => void; active: boolean; }) => { const [isDeleting, setIsDeleting] = React.useState(false); const [isHovering, setIsHovering] = React.useState(false); return (
setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} className={cn( 'group flex flex-row gap-3 items-center hover:bg-neutral-100 dark:hover:bg-neutral-900 cursor-pointer rounded-xl p-2', active && 'bg-neutral-100 dark:bg-neutral-900', )} >
value.gridSize[1] ? 'w-20 h-auto' : 'h-20 w-auto', Math.max(value.gridSize[0], value.gridSize[1]) > 10 ? 'gap-px' : 'gap-0.5', )} cellProps={{ style: { borderRadius: `${value.borderRadius}${value.borderRadiusUnit}`, }, }} duration={Number(value.duration)} animate={isHovering} cellClassName="!size-full" cellActiveClassName="bg-neutral-800 dark:bg-neutral-200" cellInactiveClassName="bg-neutral-200 dark:bg-neutral-800" gridSize={value.gridSize} frames={value.frames} />

{value.gridSize[0]}x{value.gridSize[1]} • {value.frames.length}{' '} frames

{isDeleting ? (
) : ( )}
); }; interface Animation { gridSize: [number, number]; frames: Frames; duration: string; borderRadius: string; borderRadiusUnit: string; } export const MotionGridEditor = () => { const [gridSizeInput, setGridSizeInput] = React.useState<[string, string]>( GRID_SIZE.map((n) => n.toString()) as [string, string], ); const [gridSize, setGridSize] = React.useState<[number, number]>(GRID_SIZE); const [frames, setFrames] = React.useState([[]]); const [activeFrame, setActiveFrame] = React.useState(0); const [isCopied, setIsCopied] = React.useState(false); const [isSaved, setIsSaved] = React.useState(false); const [animationName, setAnimationName] = React.useState(''); const [selectedAnimation, setSelectedAnimation] = React.useState< string | null >(null); const [animations, setAnimations] = React.useState>( {}, ); const [isDrawing, setIsDrawing] = React.useState(false); const [drawAction, setDrawAction] = React.useState<'add' | 'remove' | null>( null, ); const [duration, setDuration] = React.useState(DEFAULT_DURATION); const [borderRadius, setBorderRadius] = React.useState( DEFAULT_BORDER_RADIUS, ); const [borderRadiusUnit, setBorderRadiusUnit] = React.useState('px'); React.useEffect(() => { const handleUp = () => { setIsDrawing(false); setDrawAction(null); }; window.addEventListener('mouseup', handleUp); return () => window.removeEventListener('mouseup', handleUp); }, []); const normalizeAnimation = (anim: Partial): Animation => ({ ...anim, duration: anim.duration ?? DEFAULT_DURATION, borderRadius: anim.borderRadius ?? DEFAULT_BORDER_RADIUS, borderRadiusUnit: anim.borderRadiusUnit ?? DEFAULT_BORDER_RADIUS_UNIT, }) as Animation; React.useEffect(() => { const raw = localStorage.getItem('animations'); const parsed: Record> = raw ? JSON.parse(raw) : {}; const fixed: Record = Object.fromEntries( Object.entries(parsed).map(([key, value]) => [ key, normalizeAnimation(value), ]), ); setAnimations(fixed); localStorage.setItem('animations', JSON.stringify(fixed)); }, []); const activeFrameDots = new Set( frames[activeFrame]?.map(([x, y]) => y * gridSize[0]! + x) ?? [], ); const applyDot = (x: number, y: number, action: 'add' | 'remove') => { setFrames((prev) => { const clone = [...prev]; const current = clone[activeFrame]!; const idx = current.findIndex(([px, py]) => px === x && py === y); if (action === 'add' && idx === -1) { clone[activeFrame] = [...current, [x, y]]; } else if (action === 'remove' && idx !== -1) { clone[activeFrame] = [ ...current.slice(0, idx), ...current.slice(idx + 1), ]; } return clone; }); }; const moveCurrentFrame = (direction: -1 | 1) => { if (frames.length <= 1) return; setFrames((prev) => { const clone = [...prev]; const len = clone.length; const newIndex = (activeFrame + direction + len) % len; const [moved] = clone.splice(activeFrame, 1); clone.splice(newIndex, 0, moved!); setActiveFrame(newIndex); return clone; }); }; const removeDotNotInGrid = () => { setFrames((prev) => prev.map((frame) => frame.filter(([x, y]) => x < gridSize[0] && y < gridSize[1]), ), ); }; const createNewAnimation = () => { setGridSize(GRID_SIZE); setGridSizeInput(GRID_SIZE.map((n) => n.toString()) as [string, string]); setDuration(DEFAULT_DURATION); setBorderRadius(DEFAULT_BORDER_RADIUS); setBorderRadiusUnit(DEFAULT_BORDER_RADIUS_UNIT); setFrames([[]]); setActiveFrame(0); setAnimationName(''); setIsSaved(false); setIsCopied(false); setSelectedAnimation(null); }; return (
{Object.entries(animations).map(([name, value]) => ( { setGridSize(value.gridSize); setFrames(value.frames); setDuration(value.duration); setBorderRadius(value.borderRadius); setBorderRadiusUnit(value.borderRadiusUnit); setActiveFrame(0); setAnimationName(name); setIsSaved(false); setIsCopied(false); setGridSizeInput( value.gridSize.map((n) => n.toString()) as [string, string], ); setSelectedAnimation(name); }} deleteAnimation={() => { if (selectedAnimation === name) createNewAnimation(); const newAnimations = { ...animations }; delete newAnimations[name]; setAnimations(newAnimations); localStorage.setItem( 'animations', JSON.stringify(newAnimations), ); }} active={selectedAnimation === name} /> ))}
Animations stored in local storage
{ setGridSizeInput((prev) => [e.target.value, prev[1]]); setGridSize((prev) => [ formatGridSizeNumber(Number(e.target.value)), prev[1], ]); }} onBlur={() => { setGridSizeInput( gridSize.map((n) => n.toString()) as [string, string], ); removeDotNotInGrid(); }} /> { setGridSizeInput((prev) => [prev[0], e.target.value]); setGridSize((prev) => [ prev[0], formatGridSizeNumber(Number(e.target.value)), ]); }} onBlur={() => { setGridSizeInput( gridSize.map((n) => n.toString()) as [string, string], ); removeDotNotInGrid(); }} />
gridSize[1] ? 'w-20 h-auto' : 'h-20 w-auto', Math.max(gridSize[0], gridSize[1]) > 10 ? 'gap-px' : 'gap-0.5', )} cellProps={{ style: { borderRadius: `${borderRadius}${borderRadiusUnit}`, }, }} duration={Number(duration)} cellClassName="!size-full" cellActiveClassName="bg-neutral-800 dark:bg-neutral-200" cellInactiveClassName="bg-neutral-200 dark:bg-neutral-800" gridSize={gridSize} frames={frames} />
gridSize[1] ? 'w-[150px] sm:w-[200px]' : 'h-[150px] sm:h-[200px]', Math.max(gridSize[0], gridSize[1]) > 10 ? 'gap-0.5' : Math.max(gridSize[0], gridSize[1]) > 10 ? 'gap-1' : 'gap-1.5', )} style={{ gridTemplateColumns: `repeat(${gridSize[0]}, minmax(0, 1fr))`, gridAutoRows: '1fr', }} > {Array.from({ length: gridSize[0]! * gridSize[1]! }).map( (_, i) => (
{ const x = i % gridSize[0]!; const y = Math.floor(i / gridSize[0]!); const action: 'add' | 'remove' = activeFrameDots.has(i) ? 'remove' : 'add'; setDrawAction(action); setIsDrawing(true); applyDot(x, y, action); }} onMouseEnter={() => { if (!isDrawing || !drawAction) return; const x = i % gridSize[0]!; const y = Math.floor(i / gridSize[0]!); applyDot(x, y, drawAction); }} className={cn( 'rounded-full aspect-square hover:ring hover:ring-neutral-300 dark:hover:ring-neutral-700', activeFrameDots.has(i) ? 'bg-neutral-800 dark:bg-neutral-200' : 'bg-neutral-200 dark:bg-neutral-800', )} style={{ borderRadius: `${borderRadius}${borderRadiusUnit}`, }} /> ), )}
{frames.map((_, index) => { const activeDot = new Set( frames[index]?.map( ([x, y]) => y * gridSize[0]! + x, ) ?? [], ); return ( ); })}
Add Frame Remove Frame Move Frame Left Move Frame Right
{ const v = e.target.value; if (v === '') { setDuration(''); return; } const n = Number(v); if (!Number.isNaN(n) && n >= 0) { setDuration(v); } }} />
ms
{ const v = e.target.value; if (v === '') { setBorderRadius(''); return; } const n = Number(v); if (!Number.isNaN(n) && n >= 0) { setBorderRadius(v); } }} />
{BORDER_RADIUS_UNITS.map((unit) => ( setBorderRadiusUnit(unit)} > {unit} ))}
setAnimationName(e.target.value)} /> Reset Animation {isCopied ? 'Copied' : 'Copy Animation'} Save Animation
); };