Install
openclaw skills install loopwindGenerate images and videos from React + Tailwind CSS templates using the loopwind CLI.
openclaw skills install loopwindA CLI tool for generating images and videos from JSX templates using Tailwind CSS and Satori. Templates live in a .loopwind/ directory alongside your codebase.
Loopwind is a CLI tool for generating images and videos with React and Tailwind CSS. It's designed to be used with AI Agents and Cursor.
curl -fsSL https://loopwind.dev/install.sh | bash
This installs loopwind to ~/.loopwind/ and adds the loopwind command to your PATH. Requires Node.js 18+.
Navigate to any project folder and run:
loopwind init
This creates .loopwind/loopwind.json — a configuration file with your project's theme colors.
Give your AI agent expertise in loopwind:
npx skills add https://loopwind.dev/skill.md
This installs a skill that teaches Claude Code (or other AI agents) how to create templates, use animation classes, and render images/videos.
With the loopwind skill installed, Claude has deep knowledge of template structure, animation classes, and Tailwind CSS patterns for Satori. Just ask:
Create an OG image for my blog post about TypeScript tips
Create an animated intro video for my YouTube channel
Claude will create optimized templates and render the final output automatically.
loopwind add image-template
loopwind add video-template
Templates are installed to: .loopwind/<template>/
Benefits:
loopwind render template-name '{"title":"Hello World","subtitle":"Built with loopwind"}'
or use a local props file:
loopwind render template-name props.json
loopwind add <source>Install a template from various sources:
# Official templates
loopwind add image-template
loopwind add video-template
These will be downloaded to .loopwind/<template>/
loopwind listList all installed templates:
loopwind list
loopwind render <template> <props> [options]Render an image or video:
# Image with inline props
loopwind render banner-hero '{"title":"Hello World"}'
# Video with inline props
loopwind render video-intro '{"title":"Welcome"}'
# Using a props file
loopwind render banner-hero props.json
# Custom output
loopwind render banner-hero '{"title":"Hello"}' --out custom-name.png
# Different format
loopwind render banner-hero '{"title":"Hello"}' --format jpeg
Options:
--out, -o - Output filename (default: <template>.<ext> in current directory)--format - Output format: png, jpeg, svg (images only)--quality - JPEG quality 1-100 (default: 92)loopwind validate <template>Validate a template:
loopwind validate banner-hero
Checks:
export const meta exists and is validloopwind initInitialize loopwind in a project:
loopwind init
Creates .loopwind/loopwind.json configuration file with your project's design tokens.
Use Tailwind-style animation classes - no manual calculations needed:
// Fade in: starts at 0ms, lasts 500ms
<h1 style={tw('enter-fade-in/0/500')}>Hello</h1>
// Loop: ping effect every 500ms
<div style={tw('loop-ping/500')} />
// Combined with easing
<h1 style={tw('ease-out enter-bounce-in-up/0/600')}>Title</h1>
See Animation for the complete reference.
Templates are React components that define your images and videos. They use Tailwind CSS for styling and export metadata that loopwind uses for rendering.
loopwind add image-template
loopwind add video-template
Templates are installed to .loopwind/<template-name>/.
loopwind add https://example.com/templates/my-template.json
loopwind add ./my-templates/banner-hero
loopwind add /Users/you/templates/social-card
// .loopwind/banner-hero/template.tsx
export const meta = {
name: "banner-hero",
type: "image",
description: "Hero banner with gradient background",
size: { width: 1600, height: 900 },
props: { title: "string", subtitle: "string" }
};
export default function BannerHero({ title, subtitle, tw }) {
return (
<div style={tw('flex flex-col justify-center items-center w-full h-full bg-gradient-to-br from-purple-600 to-blue-500 p-12')}>
<h1 style={tw('text-7xl font-bold text-white mb-4')}>
{title}
</h1>
<p style={tw('text-2xl text-white/80')}>
{subtitle}
</p>
</div>
);
}
# Render with inline props
loopwind render banner-hero '{"title":"Hello World","subtitle":"Welcome"}'
# Custom output name
loopwind render banner-hero '{"title":"Hello"}' --out custom-name.png
# Different format
loopwind render banner-hero '{"title":"Hello"}' --format jpeg --quality 95
# Use a props file
loopwind render banner-hero props.json
| Format | Best For |
|---|---|
| PNG (default) | Transparency, sharp text, logos |
| JPEG | Photographs, gradients, smaller files |
| SVG | Vector graphics, scalable designs |
// .loopwind/video-intro/template.tsx
export const meta = {
name: "video-intro",
type: "video",
description: "Animated intro with bounce-in title",
size: { width: 1920, height: 1080 },
video: { fps: 30, duration: 3 },
props: { title: "string" }
};
export default function VideoIntro({ tw, title }) {
return (
<div style={tw('flex items-center justify-center w-full h-full bg-gradient-to-br from-blue-600 to-purple-700')}>
<h1 style={tw('text-8xl font-bold text-white ease-out enter-bounce-in-up/0/600')}>
{title}
</h1>
</div>
);
}
# Render with inline props
loopwind render video-intro '{"title":"Welcome!"}' --out intro.mp4
# Faster encoding with FFmpeg
loopwind render video-intro '{"title":"Welcome!"}' --ffmpeg
# Higher quality (lower CRF = better)
loopwind render video-intro '{"title":"Welcome!"}' --crf 18
video: { fps: 30, duration: 3 } // 90 frames total
| FPS | Use Case |
|---|---|
| 24 | Cinematic look, smaller files |
| 30 | Standard web video |
| 60 | Smooth animations |
Templates receive these additional props:
frame - Current frame number (0 to totalFrames - 1)progress - Animation progress from 0 to 1export default function MyVideo({ frame, progress }) {
// frame: 0, 1, 2, ... 89 (for 3s @ 30fps)
// progress: 0.0 at start, 0.5 at middle, 1.0 at end
}
| Encoder | Command | Use Case |
|---|---|---|
| WASM (default) | loopwind render ... | CI/CD, no dependencies |
| FFmpeg | loopwind render ... --ffmpeg | Faster, smaller files |
Install FFmpeg: brew install ffmpeg (macOS)
Use Tailwind-style animation classes for videos:
// Enter animations: enter-{type}/{delay}/{duration}
<h1 style={tw('enter-fade-in/0/500')}>Fade in at start</h1>
<h1 style={tw('enter-bounce-in-up/300/400')}>Bounce in after 300ms</h1>
// Exit animations: exit-{type}/{start}/{duration}
<div style={tw('exit-fade-out/2500/500')}>Fade out at 2.5s</div>
// Loop animations: loop-{type}/{duration}
<div style={tw('loop-float/1000')}>Continuous floating</div>
<div style={tw('loop-spin/1000')}>Spinning</div>
// Easing
<h1 style={tw('ease-out enter-slide-left/0/500')}>Smooth slide</h1>
See the full Animation documentation for all classes.
export const meta = {
name: "og-image",
type: "image",
size: { width: 1200, height: 630 },
props: { title: "string", description: "string" }
};
export default function OGImage({ tw, image, title, description }) {
return (
<div style={tw('flex w-full h-full bg-white')}>
<div style={tw('flex-1 flex flex-col justify-between p-12')}>
<img src={image('logo.svg')} style={tw('h-12 w-auto')} />
<div>
<h1 style={tw('text-5xl font-bold text-gray-900 mb-4')}>{title}</h1>
<p style={tw('text-xl text-gray-600')}>{description}</p>
</div>
<p style={tw('text-gray-400')}>yoursite.com</p>
</div>
</div>
);
}
export const meta = {
name: "animated-intro",
type: "video",
size: { width: 1920, height: 1080 },
video: { fps: 60, duration: 3 },
props: { title: "string", subtitle: "string" }
};
export default function AnimatedIntro({ tw, title, subtitle }) {
return (
<div style={tw('flex flex-col items-center justify-center w-full h-full bg-background')}>
<h1 style={tw('text-8xl font-bold text-foreground ease-out enter-bounce-in-up/0/400')}>
{title}
</h1>
<p style={tw('text-2xl text-muted-foreground mt-4 ease-out enter-fade-in-up/300/400')}>
{subtitle}
</p>
</div>
);
}
image() helperLayouts let you wrap templates with consistent headers, footers, and styling. A child template specifies a layout in its meta, and the layout receives the child content as a children prop.
Create a layout template that receives children:
// .loopwind/base-layout/template.tsx
export const meta = {
name: 'base-layout',
type: 'image',
size: { width: 1200, height: 630 },
props: {},
};
export default function BaseLayout({ tw, children }) {
return (
<div style={tw('flex flex-col w-full h-full bg-background')}>
{/* Header */}
<div style={tw('flex items-center px-8 py-4 border-b border-border')}>
<span style={tw('text-2xl font-bold text-primary')}>loopwind</span>
</div>
{/* Content slot */}
<div style={tw('flex flex-1')}>
{children}
</div>
{/* Footer */}
<div style={tw('flex items-center justify-between px-8 py-4 border-t border-border')}>
<span style={tw('text-muted-foreground')}>loopwind.dev</span>
</div>
</div>
);
}
Reference the layout using a relative path:
// .loopwind/blog-post/template.tsx
export const meta = {
name: 'blog-post',
type: 'image',
layout: '../base-layout', // Layout controls size
props: {
title: 'string',
excerpt: 'string',
},
};
export default function BlogPost({ tw, title, excerpt }) {
return (
<div style={tw('flex flex-col justify-center p-12')}>
<h1 style={tw('text-5xl font-bold text-foreground mb-4 text-balance')}>
{title}
</h1>
<p style={tw('text-xl text-muted-foreground leading-relaxed')}>
{excerpt}
</p>
</div>
);
}
loopwind render blog-post '{"title":"Hello World","excerpt":"My first post"}'
The output uses the layout's size (1200x630) with the child content inside.
When using a layout, the layout's size controls the final output dimensions. The child template doesn't need a size property.
Use relative paths to reference layouts:
layout: '../base-layout' // Sibling directory
layout: './shared/layout' // Subdirectory
layout: '../../layouts/main' // Parent's sibling
The layout receives:
tw, image, qr, template, etc.)children prop containing the rendered child contentframe, progress) for video layoutsexport default function Layout({ tw, children, frame, progress }) {
// tw, image, qr, template, path, textPath all available
return (
<div style={tw('flex w-full h-full')}>
{children}
</div>
);
}
Layouts work with video templates. Both the layout and child can use animations:
// .loopwind/video-layout/template.tsx
export const meta = {
name: 'video-layout',
type: 'video',
size: { width: 1920, height: 1080 },
video: { fps: 60, duration: 4 },
props: {},
};
export default function VideoLayout({ tw, children }) {
return (
<div style={tw('flex flex-col w-full h-full bg-background')}>
{/* Animated header */}
<div style={tw('flex items-center px-12 py-6 ease-out enter-slide-down/0/500')}>
<span style={tw('text-3xl font-bold text-primary')}>loopwind</span>
</div>
{/* Content */}
<div style={tw('flex flex-1')}>
{children}
</div>
{/* Animated footer */}
<div style={tw('flex px-12 py-6 ease-out enter-fade-in/500/400')}>
<span style={tw('text-muted-foreground')}>loopwind.dev</span>
</div>
</div>
);
}
Create a layout for all your OG images:
// .loopwind/og-layout/template.tsx
export const meta = {
name: 'og-layout',
type: 'image',
size: { width: 1200, height: 630 },
props: {},
};
export default function OGLayout({ tw, image, children }) {
return (
<div style={tw('flex w-full h-full bg-background')}>
{/* Content area */}
<div style={tw('flex flex-col flex-1 p-12')}>
{/* Logo */}
<div style={tw('flex items-center gap-3 mb-auto')}>
<img src={image('logo.svg')} style={tw('h-10 w-auto')} />
<span style={tw('text-2xl font-bold')}>MyBrand</span>
</div>
{/* Slot for page-specific content */}
<div style={tw('flex flex-1 items-center')}>
{children}
</div>
{/* Domain */}
<span style={tw('text-muted-foreground mt-auto')}>mybrand.com</span>
</div>
</div>
);
}
Then create page-specific templates:
// .loopwind/og-blog/template.tsx
export const meta = {
name: 'og-blog',
type: 'image',
layout: '../og-layout',
props: {
title: 'string',
author: 'string',
},
};
export default function OGBlog({ tw, title, author }) {
return (
<div style={tw('flex flex-col')}>
<span style={tw('text-sm text-muted-foreground uppercase tracking-wider mb-2')}>
Blog Post
</span>
<h1 style={tw('text-4xl font-bold text-foreground mb-4 text-balance')}>
{title}
</h1>
<span style={tw('text-muted-foreground')}>By {author}</span>
</div>
);
}
Use the image() helper to embed images in your templates. It supports loading from props, template directories, and URLs.
Pass the prop name to load an image path from props:
export const meta = {
name: "product-card",
type: "image",
size: { width: 1200, height: 630 },
props: {
title: "string",
background: "string?"
}
};
export default function ProductCard({ tw, image, title, background }) {
// Use fallback if no background prop provided
const bgSrc = background
? image('background')
: 'https://images.unsplash.com/photo-1557682250-33bd709cbe85?w=1200';
return (
<div style={tw('relative w-full h-full')}>
<img
src={bgSrc}
style={tw('absolute inset-0 w-full h-full object-cover')}
/>
<div style={tw('relative z-10 p-12')}>
<h1 style={tw('text-6xl font-bold text-white')}>{title}</h1>
</div>
</div>
);
}
The image('background') helper loads from the background prop value (file path or URL).
Load images directly from your template directory by including the file extension:
export default function ChangelogItem({ tw, image, text }) {
return (
<div style={tw('flex items-center gap-4')}>
{/* Load check.svg from template directory */}
<img
src={image('check.svg')}
style={tw('w-6 h-6')}
/>
<span style={tw('text-lg')}>{text}</span>
</div>
);
}
You can also use subdirectories:
<img src={image('assets/icons/star.svg')} />
<img src={image('shared/logo.png')} />
Template directory structure:
.loopwind/my-template/
├── template.tsx
├── check.svg ← image('check.svg')
└── assets/
└── icons/
└── star.svg ← image('assets/icons/star.svg')
The image() helper also supports loading images from URLs:
{
"background": "https://example.com/image.jpg"
}
.jpg, .jpeg).png).gif).webp).svg)Use Tailwind's object-fit utilities:
export default function ImageGrid({ tw, image, img1, img2, img3 }) {
return (
<div style={tw('flex gap-4 w-full h-full p-8 bg-gray-100')}>
{/* Cover - fills entire area, may crop */}
<img
src={image('img1')}
style={tw('w-full h-full object-cover rounded-lg')}
/>
{/* Contain - fits within area, may letterbox */}
<img
src={image('img2')}
style={tw('w-full h-full object-contain')}
/>
{/* Fill - stretches to fill */}
<img
src={image('img3')}
style={tw('w-full h-full object-fill')}
/>
</div>
);
}
Check file paths are relative to the props file:
{
"background": "./images/bg.jpg"
}
Absolute paths won't work.
Use appropriately sized images before embedding:
convert large-image.jpg -resize 1600x900 optimized.jpg
loopwind provides Tailwind-style animation classes that work with time to create smooth video animations without writing custom code.
Note: Animation classes only work with video templates and GIFs. For static images, animations will have no effect since there's no time context.
export default function MyVideo({ tw, title, subtitle }) {
return (
<div style={tw('flex flex-col items-center justify-center w-full h-full bg-black')}>
{/* Bounce in from below: starts at 0, lasts 400ms */}
<h1 style={tw('text-8xl font-bold text-white ease-out enter-bounce-in-up/0/400')}>
{title}
</h1>
{/* Fade in with upward motion: starts at 300ms, lasts 400ms */}
<p style={tw('text-2xl text-white/80 mt-4 ease-out enter-fade-in-up/300/400')}>
{subtitle}
</p>
{/* Continuous floating animation: repeats every 1s (1000ms) */}
<div style={tw('mt-8 text-4xl loop-float/1000')}>
⬇️
</div>
</div>
);
}
loopwind uses three types of animations with millisecond timing:
| Type | Format | Description |
|---|---|---|
| Enter | enter-{type}/{start}/{duration} | Animations that play when entering |
| Exit | exit-{type}/{start}/{duration} | Animations that play when exiting |
| Loop | loop-{type}/{duration} | Continuous looping animations |
All timing values are in milliseconds (1000ms = 1 second).
In addition to predefined animations, loopwind supports Tailwind utility-based animations that let you animate any transform or opacity property directly:
// Slide in 20px from the left
<div style={tw('enter-translate-x-5/0/1000')}>Content</div>
// Rotate 90 degrees on entrance
<div style={tw('enter-rotate-90/0/500')}>Spinning</div>
// Fade to 50% opacity in a loop
<div style={tw('loop-opacity-50/1000')}>Pulsing</div>
// Scale down with negative value
<div style={tw('enter--scale-50/0/800')}>Shrinking</div>
| Utility | Format | Description | Example |
|---|---|---|---|
| translate-x | enter-translate-x-{value} | Translate horizontally | enter-translate-x-5 = 20px<br/>enter-translate-x-full = 100%<br/>enter-translate-x-[20px] = 20px |
| translate-y | enter-translate-y-{value} | Translate vertically | loop-translate-y-10 = 40px<br/>enter-translate-y-1/2 = 50%<br/>enter-translate-y-[5rem] = 80px |
| opacity | enter-opacity-{n} | Set opacity (0-100) | enter-opacity-50 = 50% |
| scale | enter-scale-{n} | Scale element (0-200) | enter-scale-100 = 1.0x |
| rotate | enter-rotate-{n} | Rotate in degrees | enter-rotate-45 = 45° |
| skew-x | enter-skew-x-{n} | Skew on X axis in degrees | enter-skew-x-12 = 12° |
| skew-y | enter-skew-y-{n} | Skew on Y axis in degrees | exit-skew-y-6 = 6° |
Translate value formats:
5 = 20px (Tailwind spacing scale: 1 unit = 4px)full = 100%1/2 = 50%, 1/3 = 33.333%, 2/3 = 66.666%, etc.[20px], [5rem], [10%] (rem converts to px: 1rem = 16px)All utilities work with:
enter-, exit-, loop-, animate-- (e.g., -translate-x-5, -rotate-45)/start/duration (e.g., enter-translate-x-5/0/800)// Numeric (Tailwind spacing): 20px (5 * 4px)
<div style={tw('enter-translate-x-5/0/500')}>Content</div>
// Keyword: Full width (100%)
<div style={tw('enter-translate-y-full/0/800')}>Dropping full height</div>
// Fraction: Half width (50%)
<div style={tw('enter-translate-x-1/2/0/600')}>Slide in halfway</div>
// Arbitrary values: Exact px or rem
<div style={tw('enter-translate-y-[20px]/0/500')}>Slide 20px</div>
<div style={tw('enter-translate-x-[5rem]/0/800')}>Slide 5rem (80px)</div>
// Loop with fractions
<div style={tw('loop-translate-y-1/4/1000')}>Oscillate 25%</div>
// Negative values
<div style={tw('exit--translate-y-8/2000/500')}>Rising</div>
// Fade to 100% opacity
<div style={tw('enter-opacity-100/0/500')}>Fading In</div>
// Fade to 50% opacity
<div style={tw('enter-opacity-50/0/800')}>Half Opacity</div>
// Pulse between 50% and 100%
<div style={tw('loop-opacity-50/1000')}>Pulsing</div>
// Fade out to 0%
<div style={tw('exit-opacity-0/2500/500')}>Vanishing</div>
// Scale from 0 to 100% (1.0x)
<div style={tw('enter-scale-100/0/500')}>Growing</div>
// Scale to 150% (1.5x)
<div style={tw('enter-scale-150/0/800')}>Enlarging</div>
// Pulse scale in a loop
<div style={tw('loop-scale-110/1000')}>Breathing</div>
// Scale down to 50%
<div style={tw('exit-scale-50/2000/500')}>Shrinking</div>
// Rotate 90 degrees
<div style={tw('enter-rotate-90/0/500')}>Quarter Turn</div>
// Rotate 180 degrees
<div style={tw('enter-rotate-180/0/1000')}>Half Turn</div>
// Continuous rotation in loop (360 degrees per cycle)
<div style={tw('loop-rotate-360/2000')}>Spinning</div>
// Rotate backwards with negative value
<div style={tw('enter--rotate-45/0/500')}>Counter Rotation</div>
// Skew on X axis
<div style={tw('enter-skew-x-12/0/500')}>Slanted</div>
// Skew on Y axis
<div style={tw('enter-skew-y-6/0/800')}>Tilted</div>
// Oscillating skew in loop
<div style={tw('loop-skew-x-6/1000')}>Wobbling</div>
// Negative skew
<div style={tw('exit--skew-x-12/2000/500')}>Reverse Slant</div>
You can combine multiple utility animations on the same element:
// Translate and rotate together
<div style={tw('enter-translate-y-10/0/500 enter-rotate-45/0/500')}>
Flying In
</div>
// Fade and scale
<div style={tw('enter-opacity-100/0/800 enter-scale-100/0/800')}>
Appearing
</div>
// Enter with translate, exit with rotation
<div style={tw('enter-translate-x-5/0/500 exit-rotate-180/2500/500')}>
Slide and Spin
</div>
For more CSS-like syntax, you can use brackets with units:
// Using bracket notation with seconds
<h1 style={tw('enter-slide-up/[0.6s]/[1.5s]')}>Hello</h1>
// Using bracket notation with milliseconds
<h1 style={tw('enter-fade-in/[300ms]/[800ms]')}>World</h1>
// Mix and match - plain numbers are milliseconds
<h1 style={tw('enter-bounce-in/0/[1.2s]')}>Mixed</h1>
Format: enter-{type}/{startMs}/{durationMs}
startMs - when the animation begins (milliseconds from start)durationMs - how long the animation lastsWhen values are omitted (enter-fade-in), it uses the full video duration.
Simple opacity transitions with optional direction.
// Fade in from 0ms to 500ms
<h1 style={tw('enter-fade-in/0/500')}>Hello</h1>
// Fade in with upward motion
<h1 style={tw('enter-fade-in-up/0/600')}>Hello</h1>
| Class | Description |
|---|---|
enter-fade-in/0/500 | Fade in (opacity 0 → 1) |
enter-fade-in-up/0/500 | Fade in + slide up (30px) |
enter-fade-in-down/0/500 | Fade in + slide down (30px) |
enter-fade-in-left/0/500 | Fade in + slide from left (30px) |
enter-fade-in-right/0/500 | Fade in + slide from right (30px) |
Larger movement (100px) with fade.
// Slide in from left: starts at 0, lasts 500ms
<div style={tw('enter-slide-left/0/500')}>Content</div>
// Slide up from bottom: starts at 200ms, lasts 600ms
<div style={tw('enter-slide-up/200/600')}>Content</div>
| Class | Description |
|---|---|
enter-slide-left/0/500 | Slide in from left (100px) |
enter-slide-right/0/500 | Slide in from right (100px) |
enter-slide-up/0/500 | Slide in from bottom (100px) |
enter-slide-down/0/500 | Slide in from top (100px) |
Playful entrance with overshoot effect.
// Bounce in with scale overshoot
<h1 style={tw('enter-bounce-in/0/500')}>Bouncy!</h1>
// Bounce in from below
<div style={tw('enter-bounce-in-up/0/600')}>Pop!</div>
| Class | Description |
|---|---|
enter-bounce-in/0/500 | Bounce in with scale overshoot |
enter-bounce-in-up/0/500 | Bounce in from below |
enter-bounce-in-down/0/500 | Bounce in from above |
enter-bounce-in-left/0/500 | Bounce in from left |
enter-bounce-in-right/0/500 | Bounce in from right |
Size-based transitions.
// Scale in from 50%
<div style={tw('enter-scale-in/0/500')}>Growing</div>
// Zoom in from 0%
<div style={tw('enter-zoom-in/0/1000')}>Zooming</div>
| Class | Description |
|---|---|
enter-scale-in/0/500 | Scale up from 50% to 100% |
enter-zoom-in/0/500 | Zoom in from 0% to 100% |
Rotation-based transitions.
// Rotate in 180 degrees
<div style={tw('enter-rotate-in/0/500')}>Spinning</div>
// 3D flip on X axis
<div style={tw('enter-flip-in-x/0/500')}>Flipping</div>
| Class | Description |
|---|---|
enter-rotate-in/0/500 | Rotate in from -180° |
enter-flip-in-x/0/500 | 3D flip on horizontal axis |
enter-flip-in-y/0/500 | 3D flip on vertical axis |
Format: exit-{type}/{startMs}/{durationMs}
startMs - when the exit animation beginsdurationMs - how long the exit animation lastsExit animations use the same timing system but animate elements out.
// Fade out starting at 2500ms, lasting 500ms (ends at 3000ms)
<h1 style={tw('exit-fade-out/2500/500')}>Goodbye</h1>
// Combined enter and exit on same element
<h1 style={tw('enter-fade-in/0/500 exit-fade-out/2500/500')}>
Hello and Goodbye
</h1>
| Class | Description |
|---|---|
exit-fade-out/2500/500 | Fade out (opacity 1 → 0) |
exit-fade-out-up/2500/500 | Fade out + slide up |
exit-fade-out-down/2500/500 | Fade out + slide down |
exit-fade-out-left/2500/500 | Fade out + slide left |
exit-fade-out-right/2500/500 | Fade out + slide right |
exit-slide-up/2500/500 | Slide out upward (100px) |
exit-slide-down/2500/500 | Slide out downward (100px) |
exit-slide-left/2500/500 | Slide out to left (100px) |
exit-slide-right/2500/500 | Slide out to right (100px) |
exit-scale-out/2500/500 | Scale out to 150% |
exit-zoom-out/2500/500 | Zoom out to 200% |
exit-rotate-out/2500/500 | Rotate out to 180° |
exit-bounce-out/2500/500 | Bounce out with scale |
exit-bounce-out-up/2500/500 | Bounce out upward |
exit-bounce-out-down/2500/500 | Bounce out downward |
exit-bounce-out-left/2500/500 | Bounce out to left |
exit-bounce-out-right/2500/500 | Bounce out to right |
Format: loop-{type}/{durationMs}
Loop animations repeat every {durationMs} milliseconds:
/1000 = 1 second loop/500 = 0.5 second loop/2000 = 2 second loopWhen duration is omitted (loop-bounce), it defaults to 1000ms (1 second).
// Pulse opacity every 500ms
<div style={tw('loop-fade/500')}>Pulsing</div>
// Bounce every 800ms
<div style={tw('loop-bounce/800')}>Bouncing</div>
// Full rotation every 2000ms
<div style={tw('loop-spin/2000')}>Spinning</div>
| Class | Description |
|---|---|
loop-fade/{ms} | Opacity pulse (0.5 → 1 → 0.5) |
loop-bounce/{ms} | Bounce up and down |
loop-spin/{ms} | Full 360° rotation |
loop-ping/{ms} | Scale up + fade out (radar effect) |
loop-wiggle/{ms} | Side to side wiggle |
loop-float/{ms} | Gentle up and down floating |
loop-pulse/{ms} | Scale pulse (1.0 → 1.05 → 1.0) |
loop-shake/{ms} | Shake side to side |
Add an easing class before the animation class to control the timing curve.
// Ease in (accelerate)
<h1 style={tw('ease-in enter-fade-in/0/1000')}>Accelerating</h1>
// Ease out (decelerate) - default
<h1 style={tw('ease-out enter-fade-in/0/1000')}>Decelerating</h1>
// Ease in-out (smooth)
<h1 style={tw('ease-in-out enter-fade-in/0/1000')}>Smooth</h1>
// Strong cubic easing
<h1 style={tw('ease-out-cubic enter-bounce-in/0/500')}>Dramatic</h1>
| Class | Description | Best For |
|---|---|---|
linear | Constant speed | Mechanical motion |
ease-in | Slow start, fast end | Exit animations |
ease-out | Fast start, slow end (default) | Enter animations |
ease-in-out | Slow start and end | Subtle transitions |
ease-in-cubic | Strong slow start | Dramatic exits |
ease-out-cubic | Strong fast start | Impactful entrances |
ease-in-out-cubic | Strong both ends | Emphasis animations |
ease-in-quart | Very strong slow start | Powerful exits |
ease-out-quart | Very strong fast start | Punchy entrances |
ease-in-out-quart | Very strong both ends | Maximum drama |
You can apply different easing functions to enter, exit, and loop animations on the same element using enter-ease-*, exit-ease-*, and loop-ease-* classes.
// Different easing for enter and exit
<h1 style={tw('enter-ease-out-cubic enter-fade-in/0/500 exit-ease-in exit-fade-out/2500/500')}>
Smooth entrance, sharp exit
</h1>
// Loop with linear easing, enter with bounce
<div style={tw('enter-ease-out enter-bounce-in/0/400 loop-ease-linear loop-fade/1000')}>
Bouncy entrance, linear loop
</div>
// Default easing still works (applies to all animations)
<div style={tw('ease-in-out enter-fade-in/0/500 exit-fade-out/2500/500')}>
Same easing for both
</div>
// Mix default with specific overrides
<div style={tw('ease-out enter-fade-in/0/500 exit-ease-in-cubic exit-fade-out/2500/500')}>
Default ease-out for enter, cubic-in for exit
</div>
How it works:
ease-*) applies to ALL animations if no specific override is setenter-ease-*, exit-ease-*, loop-ease-*) overrides the default for that animation typeAvailable easing classes:
| Default (all animations) | Enter only | Exit only | Loop only |
|---|---|---|---|
ease-in | enter-ease-in | exit-ease-in | loop-ease-in |
ease-out | enter-ease-out | exit-ease-out | loop-ease-out |
ease-in-out | enter-ease-in-out | exit-ease-in-out | loop-ease-in-out |
ease-in-cubic | enter-ease-in-cubic | exit-ease-in-cubic | loop-ease-in-cubic |
ease-out-cubic | enter-ease-out-cubic | exit-ease-out-cubic | loop-ease-out-cubic |
ease-in-out-cubic | enter-ease-in-out-cubic | exit-ease-in-out-cubic | loop-ease-in-out-cubic |
ease-in-quart | enter-ease-in-quart | exit-ease-in-quart | loop-ease-in-quart |
ease-out-quart | enter-ease-out-quart | exit-ease-out-quart | loop-ease-out-quart |
ease-in-out-quart | enter-ease-in-out-quart | exit-ease-in-out-quart | loop-ease-in-out-quart |
linear | enter-ease-linear | exit-ease-linear | loop-ease-linear |
ease-spring | enter-ease-spring | exit-ease-spring | loop-ease-spring |
Spring easing creates natural, physics-based bouncy animations. Use the built-in ease-spring easing or create custom springs with configurable parameters.
// Default spring easing
<h1 style={tw('ease-spring enter-bounce-in/0/500')}>Bouncy spring!</h1>
// Per-animation-type spring
<div style={tw('enter-ease-spring enter-fade-in/0/500 exit-ease-out exit-fade-out/2500/500')}>
Spring entrance, smooth exit
</div>
// Custom spring with parameters: ease-spring/mass/stiffness/damping
<h1 style={tw('ease-spring/1/100/10 enter-scale-in/0/800')}>
Custom spring (mass=1, stiffness=100, damping=10)
</h1>
// More bouncy spring (lower damping)
<div style={tw('ease-spring/1/170/8 enter-bounce-in-up/0/600')}>
Extra bouncy!
</div>
// Stiffer spring (higher stiffness, faster)
<div style={tw('ease-spring/1/200/12 enter-fade-in-up/0/400')}>
Snappy spring
</div>
// Per-animation-type custom springs
<div style={tw('enter-ease-spring/1/150/10 enter-fade-in/0/500 exit-ease-spring/1/100/15 exit-fade-out/2500/500')}>
Different springs for enter and exit
</div>
Spring parameters:
| Parameter | Description | Effect when increased | Default |
|---|---|---|---|
| mass | Mass of the spring | Slower, more inertia | 1 |
| stiffness | Spring stiffness | Faster, snappier | 100 |
| damping | Damping coefficient | Less bounce, smoother | 10 |
Common spring presets:
// Gentle bounce (default)
ease-spring/1/100/10
// Extra bouncy
ease-spring/1/170/8
// Snappy (no bounce)
ease-spring/1/200/15
// Slow and bouncy
ease-spring/2/100/8
// Fast and tight
ease-spring/0.5/300/20
How spring works:
ease-spring - Uses a pre-calculated spring curve optimized for most use casesease-spring/mass/stiffness/damping - Generates a physics-based spring curve using the damped harmonic oscillator formulaease-spring, enter-ease-spring, exit-ease-spring, loop-ease-springYou can use both enter and exit animations on the same element:
export default function EnterExit({ tw, title }) {
return (
<div style={tw('flex items-center justify-center w-full h-full bg-black')}>
{/* Fade in during first 500ms, fade out during last 500ms (assuming 3s video) */}
<h1 style={tw('text-8xl font-bold text-white enter-fade-in/0/500 exit-fade-out/2500/500')}>
{title}
</h1>
</div>
);
}
The opacities from multiple animations are multiplied together, so you get smooth transitions that combine properly.
Create sequenced animations by offsetting start times:
export default function StaggeredList({ tw, items }) {
return (
<div style={tw('flex flex-col gap-4')}>
{/* First item: starts at 0ms, lasts 300ms */}
<div style={tw('ease-out enter-fade-in-left/0/300')}>
{items[0]}
</div>
{/* Second item: starts at 100ms, lasts 300ms */}
<div style={tw('ease-out enter-fade-in-left/100/300')}>
{items[1]}
</div>
{/* Third item: starts at 200ms, lasts 300ms */}
<div style={tw('ease-out enter-fade-in-left/200/300')}>
{items[2]}
</div>
</div>
);
}
For dynamic lists, calculate the timing programmatically:
export default function DynamicStagger({ tw, items }) {
return (
<div style={tw('flex flex-col gap-4')}>
{items.map((item, i) => {
const start = i * 100; // Each item starts 100ms later
const duration = 300; // Each animation lasts 300ms
return (
<div
key={i}
style={tw(`ease-out enter-fade-in-up/${start}/${duration}`)}
>
{item}
</div>
);
})}
</div>
);
}
export default function IntroVideo({ tw, title, subtitle, logo }) {
return (
<div style={tw('flex flex-col items-center justify-center w-full h-full bg-gradient-to-br from-blue-600 to-purple-700')}>
{/* Logo appears first */}
<img
src={logo}
style={tw('h-20 mb-8 ease-out enter-scale-in/0/300')}
/>
{/* Title bounces in */}
<h1 style={tw('text-7xl font-bold text-white ease-out enter-bounce-in-up/200/500')}>
{title}
</h1>
{/* Subtitle fades in last */}
<p style={tw('text-2xl text-white/80 mt-4 ease-out enter-fade-in-up/400/700')}>
{subtitle}
</p>
</div>
);
}
export default function TextReveal({ tw, words }) {
return (
<div style={tw('flex flex-wrap gap-2 justify-center')}>
{words.split(' ').map((word, i) => (
<span
key={i}
style={tw(`text-4xl font-bold ease-out enter-fade-in-up/${i * 100}/200`)}
>
{word}
</span>
))}
</div>
);
}
export default function AnimatedBackground({ tw, children }) {
return (
<div style={tw('relative w-full h-full')}>
{/* Floating background circles */}
<div style={tw('absolute top-10 left-10 w-20 h-20 rounded-full bg-white/10 loop-float/2000')} />
<div style={tw('absolute bottom-20 right-20 w-32 h-32 rounded-full bg-white/10 loop-fade/1500')} />
{/* Main content */}
<div style={tw('relative z-10')}>
{children}
</div>
</div>
);
}
export default function FullAnimation({ tw, title }) {
return (
<div style={tw('flex items-center justify-center w-full h-full bg-black')}>
{/* Enter: starts at 0, lasts 400ms. Exit: starts at 2600ms, lasts 400ms */}
<h1 style={tw('text-8xl font-bold text-white ease-out enter-bounce-in-up/0/400 exit-fade-out-up/2600/400')}>
{title}
</h1>
</div>
);
}
For complete control beyond animation classes, use progress and frame directly.
| Prop | Type | Description |
|---|---|---|
progress | number | 0 to 1 through the video (0% to 100%) |
frame | number | Current frame number (0, 1, 2, ... totalFrames-1) |
These are only available in video templates. Use them when animation classes aren't flexible enough.
frameexport default function FrameAnimation({ tw, frame, title }) {
// Color cycling using frame number
const hue = (frame * 5) % 360; // Cycle through colors
// Pulsing based on frame
const fps = 30;
const pulse = Math.sin(frame / fps * Math.PI * 2) * 0.2 + 0.8; // 0.6 to 1.0
return (
<div style={tw('flex items-center justify-center w-full h-full bg-black')}>
<h1 style={{
...tw('text-8xl font-bold'),
color: `hsl(${hue}, 70%, 60%)`,
transform: `scale(${pulse})`
}}>
{title}
</h1>
</div>
);
}
progressexport default function ProgressAnimation({ tw, progress, title }) {
// Custom fade based on progress
const opacity = progress < 0.3 ? progress / 0.3 : 1;
// Custom scale based on progress
const scale = 0.8 + progress * 0.2; // 0.8 to 1.0
return (
<div style={tw('flex items-center justify-center w-full h-full bg-gray-900')}>
<h1 style={{
...tw('text-8xl font-bold text-white'),
opacity,
transform: `scale(${scale})`
}}>
{title}
</h1>
</div>
);
}
export default function CustomEasing({ tw, progress, title }) {
// Smoothstep easing
const eased = progress * progress * (3 - 2 * progress);
// Elastic easing
const elastic = Math.pow(2, -10 * progress) * Math.sin((progress - 0.075) * (2 * Math.PI) / 0.3) + 1;
return (
<div style={tw('flex items-center justify-center w-full h-full')}>
<h1 style={{
...tw('text-8xl font-bold'),
opacity: eased,
transform: `translateY(${(1 - elastic) * 100}px)`
}}>
{title}
</h1>
</div>
);
}
Use progress/frame instead of animation classes when you need:
For everything else, prefer animation classes - they're simpler and more maintainable.
Animate elements along SVG paths with proper rotation using built-in path helpers:
export default function PathFollowing({ tw, progress, path }) {
// Follow a quadratic Bezier curve - one line!
const rocket = path.followQuadratic(
{ x: 200, y: 400 }, // Start point
{ x: 960, y: 150 }, // Control point
{ x: 1720, y: 400 }, // End point
progress
);
return (
<div style={{ display: 'flex', ...tw('relative w-full h-full bg-gray-900') }}>
{/* Draw the path (optional) */}
<svg width="1920" height="1080" style={{ position: 'absolute' }}>
<path
d="M 200 400 Q 960 150 1720 400"
stroke="rgba(255,255,255,0.2)"
strokeWidth={2}
fill="none"
/>
</svg>
{/* Element following the path */}
<div
style={{
position: "absolute",
left: rocket.x,
top: rocket.y,
transform: `translate(-50%, -50%) rotate(${rocket.angle}deg)`,
fontSize: '48px'
}}
>
🚀
</div>
</div>
);
}
Combine textPath helpers with animation classes to create animated text along curves:
Rotating text around a circle:
export default function RotatingCircleText({ tw, textPath, progress }) {
return (
<div style={tw('relative w-full h-full bg-black')}>
{/* Text rotates around circle using progress */}
{textPath.onCircle(
"SPINNING TEXT • AROUND • ",
960, // center x
540, // center y
400, // radius
progress, // rotation offset (0-1 animates full rotation)
{
fontSize: "3xl",
fontWeight: "bold",
color: "yellow-300"
}
)}
</div>
);
}
Animated text reveal along a path:
export default function PathTextReveal({ tw, textPath, progress }) {
// Create custom path follower that animates position
const pathFollower = (t) => {
// Only show characters up to current progress
const visibleProgress = progress * 1.5; // Extend range for smooth reveal
const opacity = t < visibleProgress ? 1 : 0;
// Follow quadratic curve
const pos = {
x: (1 - t) * (1 - t) * 200 + 2 * (1 - t) * t * 960 + t * t * 1720,
y: (1 - t) * (1 - t) * 400 + 2 * (1 - t) * t * 150 + t * t * 400,
angle: 0
};
return { ...pos, opacity };
};
return (
<div style={tw('relative w-full h-full bg-gray-900')}>
{textPath.onPath(
"REVEALING TEXT",
pathFollower,
{
fontSize: "4xl",
fontWeight: "bold",
color: "blue-300"
}
).map((char, i) => (
<div key={i} style={{ ...char.props.style, opacity: char.props.style.opacity || 1 }}>
{char}
</div>
))}
</div>
);
}
Staggered character entrance:
export default function StaggeredCircleText({ tw, textPath }) {
const text = "HELLO WORLD";
return (
<div style={tw('relative w-full h-full bg-slate-900')}>
{textPath.onCircle(
text,
960, 540, 400, 0,
{ fontSize: "4xl", fontWeight: "bold", color: "white" }
).map((char, i) => {
// Stagger fade-in: each character starts 50ms later
const staggerDelay = i * 50;
return (
<div
key={i}
style={{
...char.props.style,
...tw(`enter-fade-in/${staggerDelay}/300 enter-scale-100/${staggerDelay}/300`)
}}
>
{char.props.children}
</div>
);
})}
</div>
);
}
Text with bounce entrance along arc:
export default function BouncyArcText({ tw, textPath }) {
return (
<div style={tw('relative w-full h-full bg-gradient-to-br from-purple-600 to-blue-500')}>
{/* Draw the arc path */}
<svg width="1920" height="1080" style={{ position: 'absolute' }}>
<path
d="M 300 900 A 600 600 0 0 1 1620 900"
stroke="rgba(255,255,255,0.2)"
strokeWidth={2}
fill="none"
strokeDasharray="5 5"
/>
</svg>
{/* Text follows arc with staggered bounce */}
{textPath.onArc(
"BOUNCING ON ARC",
960, // cx
300, // cy
600, // radius
180, // start angle
360, // end angle
{ fontSize: "3xl", fontWeight: "bold", color: "white" }
).map((char, i) => (
<div
key={i}
style={{
...char.props.style,
...tw(`ease-out enter-bounce-in-up/${i * 80}/500`)
}}
>
{char.props.children}
</div>
))}
</div>
);
}
Loop animation with text on curve:
export default function LoopingCurveText({ tw, textPath, frame }) {
// Calculate wave effect using frame
const waveOffset = Math.sin(frame / 30 * Math.PI * 2) * 0.1;
return (
<div style={tw('relative w-full h-full bg-black')}>
{textPath.onQuadratic(
"WAVY TEXT",
{ x: 200, y: 400 },
{ x: 960, y: 150 },
{ x: 1720, y: 400 },
{ fontSize: "4xl", fontWeight: "bold", color: "pink-300" }
).map((char, i) => (
<div
key={i}
style={{
...char.props.style,
transform: `${char.props.style.transform} translateY(${Math.sin((i + frame) / 5) * 10}px)`
}}
>
{char.props.children}
</div>
))}
</div>
);
}
Tips for animating text paths:
progress for smooth rotation on circles and arcsenter-fade-in, enter-bounce-in, etc.i * delayMsframe for continuous effects like waves or pulsingtransform: '${char.props.style.transform} ...'Common path types:
Quadratic Bezier (Q command):
// Position: (1-t)²·P0 + 2(1-t)t·P1 + t²·P2
function pointOnQuadraticBezier(p0, p1, p2, t) {
const x = (1 - t) * (1 - t) * p0.x + 2 * (1 - t) * t * p1.x + t * t * p2.x;
const y = (1 - t) * (1 - t) * p0.y + 2 * (1 - t) * t * p1.y + t * t * p2.y;
return { x, y };
}
// Tangent angle
function angleOnQuadraticBezier(p0, p1, p2, t) {
const dx = 2 * (1 - t) * (p1.x - p0.x) + 2 * t * (p2.x - p1.x);
const dy = 2 * (1 - t) * (p1.y - p0.y) + 2 * t * (p2.y - p1.y);
return Math.atan2(dy, dx) * (180 / Math.PI);
}
Cubic Bezier (C command):
// Position: (1-t)³·P0 + 3(1-t)²t·P1 + 3(1-t)t²·P2 + t³·P3
function pointOnCubicBezier(p0, p1, p2, p3, t) {
const mt = 1 - t;
const mt2 = mt * mt;
const mt3 = mt2 * mt;
const t2 = t * t;
const t3 = t2 * t;
const x = mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x;
const y = mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y;
return { x, y };
}
// Tangent angle
function angleOnCubicBezier(p0, p1, p2, p3, t) {
const mt = 1 - t;
const mt2 = mt * mt;
const t2 = t * t;
const dx = -3 * mt2 * p0.x + 3 * mt2 * p1.x - 6 * mt * t * p1.x - 3 * t2 * p2.x + 6 * mt * t * p2.x + 3 * t2 * p3.x;
const dy = -3 * mt2 * p0.y + 3 * mt2 * p1.y - 6 * mt * t * p1.y - 3 * t2 * p2.y + 6 * mt * t * p2.y + 3 * t2 * p3.y;
return Math.atan2(dy, dx) * (180 / Math.PI);
}
Circle:
function pointOnCircle(cx, cy, radius, angleRadians) {
return {
x: cx + radius * Math.cos(angleRadians),
y: cy + radius * Math.sin(angleRadians)
};
}
// Usage
const angleRadians = progress * Math.PI * 2;
const pos = pointOnCircle(300, 300, 100, angleRadians);
const tangentAngle = (angleRadians * 180 / Math.PI) + 90; // Tangent is perpendicular
Tips:
progress (0-1) for smooth animationtranslate(-50%, -50%) centers the element on the pathtranslate(-50%, -50%) rotate(${angle}deg)Animate SVG path strokes with the stroke-dash classes, perfect for drawing or erasing line art, icons, and illustrations.
SVG stroke animations use strokeDasharray and strokeDashoffset CSS properties to create drawing effects:
All stroke-dash animations require the path length in brackets:
enter-stroke-dash-[length]/start/duration
exit-stroke-dash-[length]/start/duration
loop-stroke-dash-[length]/duration
export default function SVGAnimation({ tw }) {
return (
<svg width="400" height="200" viewBox="0 0 400 200">
{/* Draw a curve over 1 second */}
<path
d="M10 150 Q 95 10 180 150"
stroke="black"
strokeWidth={4}
fill="none"
style={tw('enter-stroke-dash-[300]/0/1000')}
/>
</svg>
);
}
Draw strokes from 0% to 100%:
// Draw a 300px path over 1 second
<path style={tw('enter-stroke-dash-[300]/0/1000')} />
// Draw with spring easing
<path style={tw('ease-spring enter-stroke-dash-[500]/0/1500')} />
// Stagger multiple paths
<path style={tw('enter-stroke-dash-[200]/0/600')} />
<path style={tw('enter-stroke-dash-[200]/200/600')} />
<path style={tw('enter-stroke-dash-[200]/400/600')} />
Erase strokes from 100% to 0%:
// Erase starting at 2000ms, lasting 500ms
<path style={tw('exit-stroke-dash-[300]/2000/500')} />
// Draw then erase the same path
<path style={tw('enter-stroke-dash-[400]/0/800 exit-stroke-dash-[400]/2200/800')} />
Continuously draw and erase:
// Loop every 2 seconds (draws in first half, erases in second half)
<path style={tw('loop-stroke-dash-[300]/2000')} />
// Faster loop
<path style={tw('loop-stroke-dash-[200]/1000')} />
To find the path length for your SVG:
// In browser console or component:
const path = document.querySelector('path');
const length = path.getTotalLength();
console.log(length); // e.g., 347.89
Then use that value:
<path style={tw('enter-stroke-dash-[347.89]/0/1000')} />
export default function DrawingEffect({ tw }) {
return (
<div style={tw('flex items-center justify-center w-full h-full bg-gray-900')}>
<svg width="600" height="400" viewBox="0 0 600 400">
{/* Checkmark icon drawn in sequence */}
<path
d="M100 200 L 200 300 L 400 100"
stroke="#10b981"
strokeWidth={8}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
style={tw('ease-out enter-stroke-dash-[600]/0/1200')}
/>
{/* Circle drawn after checkmark */}
<circle
cx="250"
cy="200"
r="150"
stroke="#10b981"
strokeWidth={6}
fill="none"
style={tw('ease-out enter-stroke-dash-[942]/1000/1000')}
/>
</svg>
</div>
);
}
Stroke animations work alongside other animation classes:
// Fade in while drawing
<path style={tw('enter-stroke-dash-[300]/0/1000 enter-fade-in/0/1000')} />
// Draw with pulsing color
<svg>
<path
stroke="url(#gradient)"
style={tw('enter-stroke-dash-[500]/0/1500')}
/>
<defs>
<linearGradient id="gradient">
<stop offset="0%" stopColor="#8b5cf6" />
<stop offset="100%" stopColor="#ec4899" />
</linearGradient>
</defs>
</svg>
For marching ants or animated dashed patterns, use frame or progress directly instead of animation classes:
export default function MarchingAnts({ tw, frame }) {
// Calculate animated offset (loops every 30 frames)
const dashOffset = -(frame % 30) * 2;
return (
<div style={tw('flex items-center justify-center w-full h-full bg-gray-900')}>
<svg width="600" height="400" viewBox="0 0 600 400">
{/* Marching ants border */}
<rect
x="50"
y="50"
width="500"
height="300"
fill="none"
stroke="#3b82f6"
strokeWidth={3}
strokeDasharray="10 5"
strokeDashoffset={dashOffset}
/>
{/* Animated circle with different speed */}
<circle
cx="300"
cy="200"
r="80"
fill="none"
stroke="#10b981"
strokeWidth={4}
strokeDasharray="15 8"
strokeDashoffset={dashOffset * 1.5}
/>
</svg>
</div>
);
}
Tips:
strokeDasharray="10 5" - 10px dash, 5px gapstrokeDashoffset={dashOffset} - animates the pattern positiondashOffset * 2)This technique is different from stroke-dash classes:
stroke-dash classes - Draw/erase the stroke (reveal animation)Additional helpers for creating powerful, composable templates.
Beyond the basics, loopwind provides:
template() - Compose templates togetherqr() - Generate QR codes on the flyconfig - Access user configurationFor image embedding, see the Images page.
Compose multiple templates together to create complex designs.
export default function CompositeCard({ tw, template, title, author, avatar }) {
return (
<div style={tw('w-full h-full bg-gradient-to-br from-purple-600 to-blue-500 p-12')}>
<div style={tw('bg-white rounded-2xl p-8 shadow-xl')}>
<h1 style={tw('text-4xl font-bold text-gray-900 mb-6')}>{title}</h1>
{/* Embed another template */}
<div style={tw('mb-6')}>
{template('user-badge', {
name: author,
avatar: avatar
})}
</div>
<p style={tw('text-gray-600')}>Published by {author}</p>
</div>
</div>
);
}
How it works:
template(name, props) renders another installed template1. Reusable components:
// Create a logo template once, use it everywhere
<div>{template('company-logo', { variant: 'dark' })}</div>
2. Complex layouts:
// Combine multiple templates into one design
<div style={tw('grid grid-cols-2 gap-4')}>
{template('product-card', { product: product1 })}
{template('product-card', { product: product2 })}
</div>
3. Dynamic content:
// Render templates based on data
{users.map(user =>
template('user-avatar', { name: user.name, image: user.avatar })
)}
Generate QR codes dynamically in your templates.
export default function QRCard({ tw, qr, title, url }) {
return (
<div style={tw('flex flex-col items-center justify-center w-full h-full bg-white p-10')}>
<h1 style={tw('text-4xl font-bold text-black mb-8')}>{title}</h1>
{/* Generate QR code for the URL */}
<img src={qr(url)} style={tw('w-64 h-64')} />
<p style={tw('text-gray-600 mt-4')}>{url}</p>
</div>
);
}
Props format:
{
"title": "Scan Me",
"url": "https://example.com"
}
You can customize QR code appearance:
// Basic QR code
<img src={qr('https://example.com')} />
// With error correction level
<img src={qr('https://example.com', { errorCorrectionLevel: 'H' })} />
// With custom size
<img src={qr('https://example.com', { width: 512 })} />
Error correction levels:
L - Low (~7% correction)M - Medium (~15% correction) - defaultQ - Quartile (~25% correction)H - High (~30% correction)Access user settings from .loopwind/loopwind.json using the config prop:
export default function BrandedTemplate({ tw, config, title }) {
// Access custom colors from loopwind.json
const primaryColor = config?.colors?.brand || '#6366f1';
return (
<div style={tw('w-full h-full p-12')}>
<h1 style={{
...tw('text-6xl font-bold'),
color: primaryColor
}}>
{title}
</h1>
</div>
);
}
User's .loopwind/loopwind.json:
{
"colors": {
"brand": "#ff6b6b"
},
"fonts": {
"sans": ["Inter", "system-ui", "sans-serif"]
}
}
This allows templates to adapt to user preferences and brand guidelines.
Render text along curves, circles, and custom paths with automatic character positioning and rotation.
export default function CircleText({ tw, textPath, message }) {
return (
<div style={tw('relative w-full h-full bg-slate-900')}>
{textPath.onCircle(
message,
960, // center x
540, // center y
400, // radius
0, // rotation offset (0-1)
{
fontSize: "4xl",
fontWeight: "bold",
color: "white",
letterSpacing: 0.05
}
)}
</div>
);
}
All textPath functions return an array of positioned character elements:
textPath.onCircle(text, cx, cy, radius, offset, options?)
// Text around a circle
textPath.onCircle("HELLO WORLD", 960, 540, 400, 0, {
fontSize: "4xl",
color: "white"
})
textPath.onPath(text, pathFollower, options?)
// Text along any custom path
textPath.onPath("CUSTOM PATH", (t) => ({
x: 100 + t * 800,
y: 200 + Math.sin(t * Math.PI) * 100,
angle: Math.cos(t * Math.PI) * 20
}), {
fontSize: "2xl",
fontWeight: "semibold"
})
textPath.onQuadratic(text, p0, p1, p2, options?)
// Text along a quadratic Bezier curve
textPath.onQuadratic(
"CURVED TEXT",
{ x: 200, y: 400 }, // start
{ x: 960, y: 100 }, // control point
{ x: 1720, y: 400 }, // end
{ fontSize: "3xl", color: "blue-300" }
)
textPath.onCubic(text, p0, p1, p2, p3, options?)
// Text along a cubic Bezier curve
textPath.onCubic(
"S-CURVE",
{ x: 200, y: 600 }, // start
{ x: 600, y: 400 }, // control 1
{ x: 1320, y: 800 }, // control 2
{ x: 1720, y: 600 }, // end
{ fontSize: "3xl", color: "purple-300" }
)
textPath.onArc(text, cx, cy, radius, startAngle, endAngle, options?)
// Text along a circular arc
textPath.onArc(
"ARC TEXT",
960, // center x
540, // center y
400, // radius
0, // start angle (degrees)
180, // end angle (degrees)
{ fontSize: "2xl", color: "pink-300" }
)
All textPath functions accept an optional options object:
{
fontSize?: string; // Tailwind size: "xl", "2xl", "4xl", etc.
fontWeight?: string; // Tailwind weight: "bold", "semibold", etc.
color?: string; // Tailwind color: "white", "blue-500", etc.
letterSpacing?: number; // Space between characters (0-1, default: 0)
style?: any; // Additional inline styles
}
Animated rotating text:
export default function RotatingText({ tw, textPath, progress }) {
return (
<div style={tw('relative w-full h-full bg-black')}>
{textPath.onCircle(
"SPINNING • TEXT • ",
960, 540, 400,
progress, // Rotate based on video progress
{ fontSize: "3xl", color: "yellow-300" }
)}
</div>
);
}
Multiple text paths:
export default function MultiPath({ tw, textPath }) {
return (
<div style={tw('relative w-full h-full bg-gradient-to-br from-slate-900 to-slate-700')}>
{/* Text on outer circle */}
{textPath.onCircle(
"OUTER RING",
960, 540, 500, 0,
{ fontSize: "5xl", fontWeight: "bold", color: "white" }
)}
{/* Text on inner circle */}
{textPath.onCircle(
"inner ring",
960, 540, 300, 0.5, // offset by 50% for rotation
{ fontSize: "2xl", color: "white/60" }
)}
</div>
);
}
Text following a drawn path:
export default function PathText({ tw, textPath }) {
return (
<div style={tw('relative w-full h-full bg-gray-900')}>
{/* Draw the path */}
<svg width="1920" height="1080" style={{ position: 'absolute' }}>
<path
d="M 200 400 Q 960 150 1720 400"
stroke="rgba(255,255,255,0.2)"
strokeWidth={2}
fill="none"
/>
</svg>
{/* Text following the path */}
{textPath.onQuadratic(
"FOLLOWING THE CURVE",
{ x: 200, y: 400 },
{ x: 960, y: 150 },
{ x: 1720, y: 400 },
{ fontSize: "3xl", fontWeight: "bold", color: "blue-300" }
)}
</div>
);
}
For animated text paths, see Text Path Animations.
The following prop names are reserved and cannot be used in your template's meta.props:
tw, qr, image, template - Core helperspath, textPath - Path and text helpersconfig, frame, progress - System propsWhy? These names are used for loopwind's built-in helpers. Using them as prop names would cause conflicts.
Example:
// ❌ BAD - 'image' is reserved
export const meta = {
props: {
title: "string",
image: "string" // Error!
}
};
// ✅ GOOD - Use descriptive alternatives
export const meta = {
props: {
title: "string",
imageUrl: "string", // or imageSrc, photoUrl, etc.
logoUrl: "string"
}
};
If you try to use a reserved name, you'll get a helpful error:
Template uses reserved prop names: image
Try renaming: "image" → "imageUrl" or "imageSrc"
Reserved names: tw, qr, image, template, path, textPath, config, frame, progress
Every template receives these props:
export default function MyTemplate({
// Core helpers (RESERVED - cannot be used as prop names)
tw, // Tailwind class converter
qr, // QR code generator (this page)
template, // Template composer (this page)
config, // User config from loopwind.json (this page)
textPath, // Text on path helpers (this page)
// Media helpers (RESERVED)
image, // Image embedder → see /images
path, // Path following → see /animation
// Video-specific (RESERVED - only in video templates)
frame, // Current frame number → see /templates
progress, // Animation progress 0-1 → see /templates
// Your custom props (use any names EXCEPT the reserved ones above)
...props // Any props from your meta.props
}) {
// Your template code
}
Style your templates with Tailwind utility classes and shadcn/ui's beautiful design system.
export default function MyTemplate({ title, tw }) {
return (
<div style={tw('flex items-center justify-center w-full h-full bg-gradient-to-br from-blue-600 to-purple-700')}>
<h1 style={tw('text-7xl font-bold text-white')}>
{title}
</h1>
</div>
);
}
tw() FunctionEvery template receives a tw() function that converts Tailwind classes to inline styles compatible with Satori:
// Tailwind classes
tw('flex items-center justify-center p-8 bg-blue-500')
// Converts to inline styles:
{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
backgroundColor: '#3b82f6'
}
export default function Banner({ title, subtitle, tw }) {
return (
<div style={tw('w-full h-full p-12 bg-gray-50')}>
<h1 style={tw('text-6xl font-bold text-gray-900 mb-4')}>
{title}
</h1>
<p style={tw('text-2xl text-gray-600')}>
{subtitle}
</p>
</div>
);
}
Mix Tailwind classes with custom styles using the spread operator:
export default function CustomGradient({ title, tw }) {
return (
<div
style={{
...tw('flex flex-col items-center justify-center w-full h-full p-20'),
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}
>
<h1 style={tw('text-8xl font-bold text-white')}>{title}</h1>
</div>
);
}
loopwind uses shadcn/ui's design system by default, providing semantic color tokens for beautiful, consistent designs.
All templates automatically have access to these semantic colors defined in .loopwind/loopwind.json:
colors: {
// Primary colors
primary: '#18181b', // Main brand color
'primary-foreground': '#fafafa',
// Secondary colors
secondary: '#f4f4f5', // Subtle accents
'secondary-foreground': '#18181b',
// Background
background: '#ffffff', // Page background
foreground: '#09090b', // Main text color
// Muted
muted: '#f4f4f5', // Subtle backgrounds
'muted-foreground': '#71717a', // Muted text
// Accent
accent: '#f4f4f5', // Highlight color
'accent-foreground': '#18181b',
// Destructive
destructive: '#ef4444', // Error/danger states
'destructive-foreground': '#fafafa',
// UI Elements
border: '#e4e4e7', // Border color
input: '#e4e4e7', // Input borders
ring: '#18181b', // Focus rings
card: '#ffffff', // Card background
'card-foreground': '#09090b',
}
export default function SemanticCard({ title, description, price, tw }) {
return (
<div style={tw('bg-card border border-border rounded-lg p-6')}>
<h2 style={tw('text-card-foreground text-2xl font-bold mb-2')}>
{title}
</h2>
<p style={tw('text-muted-foreground mb-4')}>
{description}
</p>
<div style={tw('text-primary text-3xl font-bold')}>
${price}
</div>
</div>
);
}
Use Tailwind's slash syntax for opacity with any color:
export default function OpacityExample({ tw }) {
return (
<div style={tw('bg-primary/50')}> {/* 50% opacity */}
<p style={tw('text-muted-foreground/75')}> {/* 75% opacity */}
Subtle text
</p>
<div style={tw('border border-border/30')}> {/* 30% opacity */}
Faint border
</div>
</div>
);
}
Supported syntax:
bg-{color}/{opacity} - Background with opacitytext-{color}/{opacity} - Text with opacityborder-{color}/{opacity} - Border with opacity// Primary text
tw('text-foreground')
// Secondary/muted text
tw('text-muted-foreground')
// Accent/brand text
tw('text-primary')
// Destructive/error text
tw('text-destructive')
// Page background
tw('bg-background')
// Card/elevated surfaces
tw('bg-card')
// Subtle backgrounds
tw('bg-muted')
// Accent backgrounds
tw('bg-accent')
flex, inline-flex, block, inline-block, hiddenflex-row, flex-col, flex-row-reverse, flex-col-reversejustify-start, justify-end, justify-center, justify-between, justify-arounditems-start, items-end, items-center, items-baseline, items-stretchp-{n}, px-{n}, py-{n}, pt-{n}, pb-{n}, pl-{n}, pr-{n}m-{n}, mx-{n}, my-{n}, mt-{n}, mb-{n}, ml-{n}, mr-{n}gap-{n}, gap-x-{n}, gap-y-{n}Examples:
tw('p-4') // padding: 1rem
tw('px-8') // paddingLeft: 2rem, paddingRight: 2rem
tw('m-6') // margin: 1.5rem
tw('gap-4') // gap: 1rem
w-{n}, w-full, w-screen, w-1/2, w-1/3, w-2/3h-{n}, h-full, h-screenExamples:
tw('w-full') // width: 100%
tw('h-64') // height: 16rem
tw('w-1/2') // width: 50%
text-xs, text-sm, text-base, text-lg, text-xl, text-2xl, text-3xl, text-4xl, text-5xl, text-6xl, text-7xl, text-8xl, text-9xlfont-thin, font-light, font-normal, font-medium, font-semibold, font-bold, font-extrabold, font-blacktext-left, text-center, text-rightleading-none, leading-tight, leading-normal, leading-relaxed, leading-looseAll standard Tailwind colors plus shadcn semantic colors:
Standard colors:
text-{color}-{shade}, bg-{color}-{shade}, border-{color}-{shade}red, blue, green, yellow, purple, pink, gray, indigo, teal, orange50, 100, 200, 300, 400, 500, 600, 700, 800, 900shadcn semantic colors:
text-foreground, text-primary, text-muted-foreground, text-destructivebg-background, bg-card, bg-muted, bg-accent, bg-primaryborder-border, border-inputtw('text-blue-500') // Standard Tailwind color
tw('bg-purple-600') // Standard Tailwind color
tw('text-primary') // shadcn semantic color
tw('bg-card') // shadcn semantic color
relative, absolute, fixed, stickyinset-0, top-0, bottom-0, left-0, right-0z-0, z-10, z-20, z-30, z-40, z-50border, border-{n}, border-t, border-b, border-l, border-rrounded, rounded-sm, rounded-md, rounded-lg, rounded-xl, rounded-2xl, rounded-3xl, rounded-fullborder-{color}-{shade}, border-border, border-inputshadow-sm, shadow, shadow-md, shadow-lg, shadow-xl, shadow-2xlopacity-0, opacity-25, opacity-50, opacity-75, opacity-100blur-none, blur-sm, blur, blur-md, blur-lg, blur-xlbrightness-0, brightness-50, brightness-100, brightness-150, brightness-200contrast-0, contrast-50, contrast-100, contrast-150, contrast-200// Gradient direction
tw('bg-gradient-to-r') // left to right
tw('bg-gradient-to-br') // top-left to bottom-right
tw('bg-gradient-to-t') // bottom to top
// Gradient colors
tw('from-blue-500') // Start color
tw('via-purple-500') // Middle color
tw('to-pink-500') // End color
// Complete gradient
tw('bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500')
export default function GradientCard({ title, tw }) {
return (
<div style={tw('w-full h-full bg-gradient-to-br from-cyan-500 to-blue-600 p-12')}>
<h1 style={tw('text-white text-6xl font-bold')}>
{title}
</h1>
</div>
);
}
You can override the default shadcn colors or add your own custom colors in .loopwind/loopwind.json:
{
"theme": {
"colors": {
"primary": "#3b82f6",
"primary-foreground": "#ffffff",
"accent": "#10b981",
"brand": "#ff6b6b"
}
}
}
Then use these custom colors in your templates:
tw('text-brand') // Uses your custom brand color
tw('bg-primary') // Uses your custom primary color
tw('bg-accent') // Uses your custom accent color
loopwind automatically detects and loads your project's Tailwind configuration:
your-project/
├── tailwind.config.js ← Automatically detected
└── .loopwind/
├── loopwind.json
└── templates/
This includes:
export default function ModernCard({
tw,
image,
title,
description,
category,
author,
avatar
}) {
return (
<div style={tw('w-full h-full bg-card')}>
{/* Hero image */}
<div style={tw('relative h-2/3')}>
<img
src={image(hero)}
style={tw('w-full h-full object-cover')}
/>
{/* Category badge */}
<div style={tw('absolute top-4 left-4 bg-primary/90 backdrop-blur px-4 py-2 rounded-full')}>
<span style={tw('text-sm font-semibold text-primary-foreground')}>
{category}
</span>
</div>
</div>
{/* Content */}
<div style={tw('h-1/3 p-8 flex flex-col justify-between')}>
<div>
<h2 style={tw('text-3xl font-bold text-foreground mb-2')}>
{title}
</h2>
<p style={tw('text-muted-foreground line-clamp-2')}>
{description}
</p>
</div>
{/* Author */}
<div style={tw('flex items-center gap-3')}>
<img
src={image(avatar)}
style={tw('w-10 h-10 rounded-full border-2 border-border')}
/>
<span style={tw('text-sm text-muted-foreground')}>
{author}
</span>
</div>
</div>
</div>
);
}
text-primary instead of text-blue-600The recommended way to use fonts is through loopwind.json - configure fonts once, use everywhere.
Configure fonts in your .loopwind/loopwind.json and use Tailwind classes in templates.
Define font families in .loopwind/loopwind.json without loading custom fonts (uses system fonts):
{
"fonts": {
"sans": ["Inter", "system-ui", "-apple-system", "sans-serif"],
"serif": ["Georgia", "serif"],
"mono": ["Courier New", "monospace"]
}
}
Template usage:
export default function({ title, tw }) {
return (
<div style={tw('w-full h-full')}>
{/* Uses fonts.sans from loopwind.json */}
<h1 style={tw('font-sans text-6xl font-bold')}>
{title}
</h1>
{/* Uses fonts.mono from loopwind.json */}
<code style={tw('font-mono text-sm')}>
{code}
</code>
</div>
);
}
Result: Uses system fonts, falls back to Inter for rendering.
Load custom font files for brand-specific typography in .loopwind/loopwind.json:
{
"fonts": {
"sans": {
"family": ["Inter", "system-ui", "sans-serif"],
"files": [
{ "path": "./fonts/Inter-Regular.woff", "weight": 400 },
{ "path": "./fonts/Inter-Bold.woff", "weight": 700 }
]
},
"mono": {
"family": ["JetBrains Mono", "monospace"],
"files": [
{ "path": "./fonts/JetBrainsMono-Regular.woff", "weight": 400 }
]
}
}
}
Project structure:
your-project/
├── .loopwind/
│ ├── loopwind.json
│ └── templates/
└── fonts/
├── Inter-Regular.woff
├── Inter-Bold.woff
└── JetBrainsMono-Regular.woff
Template usage (same as before):
<h1 style={tw('font-sans font-bold')}>
{/* Uses Inter Bold from loopwind.json */}
{title}
</h1>
Available classes:
font-sans - Uses fonts.sans from loopwind.jsonfont-serif - Uses fonts.serif from loopwind.jsonfont-mono - Uses fonts.mono from loopwind.jsonSupported formats:
.woff) - Recommended for best compatibility.ttf) - Also supported.otf) - Also supported.woff2) - Not supported by rendererloopwind loads fonts in this order:
files)This ensures fonts work out of the box with no configuration.
If no fonts are configured, loopwind uses Inter (Regular 400, Bold 700) which is bundled with the CLI. This means fonts work offline with no configuration required.
tw('font-sans') instead of fontFamily: 'Inter'["Inter", "system-ui", "sans-serif"]family array is used as the loaded font nameloopwind.json location{
"fonts": {
"sans": ["Inter", "-apple-system", "sans-serif"]
}
}
Uses system Inter if available, falls back to Noto Sans for rendering.
{
"fonts": {
"sans": {
"family": ["Montserrat", "sans-serif"],
"files": [
{ "path": "./fonts/Montserrat-Regular.woff", "weight": 400 },
{ "path": "./fonts/Montserrat-Bold.woff", "weight": 700 }
]
}
}
}
Loads and uses Montserrat for all templates.
{
"fonts": {
"sans": {
"family": ["Inter", "sans-serif"],
"files": [
{ "path": "./fonts/Inter-Regular.woff", "weight": 400 },
{ "path": "./fonts/Inter-Bold.woff", "weight": 700 }
]
},
"serif": {
"family": ["Playfair Display", "serif"],
"files": [
{ "path": "./fonts/Playfair-Regular.woff", "weight": 400 }
]
},
"mono": {
"family": ["Fira Code", "monospace"],
"files": [
{ "path": "./fonts/FiraCode-Regular.woff", "weight": 400 }
]
}
}
}
Loads different fonts for each style class.
Load fonts directly from CDNs without downloading files:
{
"fonts": {
"sans": {
"family": ["Inter", "sans-serif"],
"files": [
{
"path": "https://unpkg.com/@fontsource/inter@5.0.18/files/inter-latin-400-normal.woff",
"weight": 400
},
{
"path": "https://unpkg.com/@fontsource/inter@5.0.18/files/inter-latin-700-normal.woff",
"weight": 700
}
]
}
}
}
You can also mix local and external fonts:
{
"fonts": {
"sans": {
"family": ["Inter", "sans-serif"],
"files": [
{ "path": "./fonts/Inter-Regular.woff", "weight": 400 },
{
"path": "https://unpkg.com/@fontsource/inter@5.0.18/files/inter-latin-700-normal.woff",
"weight": 700
}
]
}
}
}
Note: Use WOFF format (.woff) for best compatibility. WOFF2 is not supported by the underlying renderer.
Configure colors and fonts for all your templates in .loopwind/loopwind.json.
your-project/
├── .loopwind/
│ ├── loopwind.json ← Configuration file
│ └── templates/
{
"theme": {
"colors": {
"primary": "#3b82f6",
"background": "#ffffff"
}
}
}
{
"theme": {
"colors": {
"primary": "#18181b",
"primary-foreground": "#fafafa",
"secondary": "#f4f4f5",
"secondary-foreground": "#18181b",
"background": "#ffffff",
"foreground": "#09090b",
"muted": "#f4f4f5",
"muted-foreground": "#71717a",
"accent": "#f4f4f5",
"accent-foreground": "#18181b",
"destructive": "#ef4444",
"destructive-foreground": "#fafafa",
"border": "#e4e4e7",
"input": "#e4e4e7",
"ring": "#18181b",
"card": "#ffffff",
"card-foreground": "#09090b"
}
}
}
{
"theme": {
"colors": {
"primary": "#3b82f6",
"brand": "#ff6b6b",
"success": "#22c55e",
"warning": "#f59e0b"
}
}
}
Use in templates:
tw('text-brand') // #ff6b6b
tw('bg-success') // #22c55e
tw('border-warning') // #f59e0b
{
"fonts": {
"sans": ["Inter", "system-ui", "sans-serif"],
"serif": ["Georgia", "serif"],
"mono": ["Courier New", "monospace"]
}
}
{
"fonts": {
"sans": {
"family": ["Inter", "system-ui", "sans-serif"],
"files": [
{ "path": "./fonts/Inter-Regular.woff", "weight": 400 },
{ "path": "./fonts/Inter-Bold.woff", "weight": 700 }
]
}
}
}
Paths are relative to loopwind.json.
Supported formats:
.woff).ttf).otf).woff2){
"fonts": {
"sans": {
"family": ["Inter", "sans-serif"],
"files": [
{
"path": "https://unpkg.com/@fontsource/inter@5.0.18/files/inter-latin-400-normal.woff",
"weight": 400
}
]
}
}
}
{
"theme": {
"colors": {
"primary": "#6366f1",
"primary-foreground": "#ffffff",
"background": "#ffffff",
"foreground": "#0f172a",
"muted": "#f1f5f9",
"muted-foreground": "#64748b",
"border": "#e2e8f0",
"card": "#ffffff",
"brand": "#8b5cf6"
}
},
"fonts": {
"sans": {
"family": ["Inter", "sans-serif"],
"files": [
{ "path": "./fonts/Inter-Regular.woff", "weight": 400 },
{ "path": "./fonts/Inter-Bold.woff", "weight": 700 }
]
},
"serif": {
"family": ["Playfair Display", "serif"],
"files": [
{ "path": "./fonts/Playfair-Regular.woff", "weight": 400 }
]
}
}
}
{
"theme"?: {
"colors"?: {
[name: string]: string; // Hex color
}
},
"fonts"?: {
[class: string]: string[] | {
family: string[];
files: Array<{
path: string; // Local or URL
weight: number; // 100-900
}>;
}
}
}
If no loopwind.json exists, loopwind auto-detects tailwind.config.js:
your-project/
├── tailwind.config.js ← Auto-detected
└── .loopwind/
└── templates/
Priority:
.loopwind/loopwind.jsontailwind.config.js