Ripple TypeSVG text animation warped by a turbulence filter.
TypographyHero sectionEditorialBrand site
Switch to light
Refresh
Full screen
OFF
Loading source…
Add to your project
One command adds this component to your project.
1
Run the following command. New project? Run npx shadcn@latest init first to set up Tailwind and path aliases.
npx shadcn@latest add @aicanvas/ripple-type
2
For dark mode, add the dark class to your <html> element:
Optional
<html class="dark">
Install with AI Canvas MCP
With AI Canvas MCP, your AI knows every component we ship. Ask for one inside Claude Code, Codex, or Cursor and it installs the component you pick. Works with any AI Canvas account, free or premium.
Ripple Type is an SVG word warped by an SVG turbulence filter, with a small fan-style toggle that, when activated, sends the letters rippling like silk caught in a draft. Motion controls the animation of the filter parameters so the ripple breathes rather than running at a fixed frequency. It is the right pick for editorial hero sections, brand sites with a poetic tone, and any landing page where the headline should physically respond to the page state.
Built with
MotionTailwind CSS
Frequently asked questions
Is Ripple Type free to use?
Yes. Ripple Type is part of the free AI Canvas library and is open source under the MIT license, so you can use it in personal and commercial projects.
How do I install Ripple Type?
One command: npx shadcn@latest add https://aicanvas.me/r/ripple-type.json, free with a free AI Canvas account. Or connect the AI Canvas MCP server and ask your AI editor to install Ripple Type for you. You can also copy the source straight from the Code tab, no account needed. It works in any React project with Tailwind CSS.
What is Ripple Type built with?
Ripple Type is built with React and TypeScript, using Motion and Tailwind CSS. It ships with both light and dark styling.
Where would I use Ripple Type?
Common uses include Hero section, Editorial, and Brand site. Like every AI Canvas component, it is self-contained and drops into any React project.
Can I remix Ripple Type with AI?
Yes. Ripple Type ships with one comprehensive AI prompt written against the real source code. Open "Remix with AI" on this page to read and copy it into Claude, Cursor, ChatGPT, or any AI tool. Prompts are for remixing your own variation; for the exact component, install it with the one-command CLI.
One comprehensive prompt, written against the real source code. Works in Claude, Cursor, ChatGPT, or any AI tool you use.
This prompt is for remixing. Use it to build your own variation of Ripple Type. Results depend on the model you use, and no prompt in the world is 100% exact.
Want the exact component?
One command installs it, pixel-perfect. Copy, paste into your project, done.
npx shadcn@latest add @aicanvas/ripple-type
AI prompt for Ripple Type
Before writing any code, verify the project has Tailwind CSS v4, TypeScript, and React set up. If missing, use the shadcn CLI to scaffold them.
Build a single-file React component called `RippleType` with `'use client'` at the top. Install dependency: `framer-motion`.
---
## Concept
A kinetic-typography piece: the word `RIPPLE` rendered as large SVG text with an SVG turbulence + displacement-map filter that distorts the letters like rippling liquid. To the left of the word sits a small fan icon with an OFF/ON label below it. Click the fan to toggle. When ON, the displacement intensity ramps up over a second, the noise pattern morphs continuously, the fan blades spin, and the word skews to the right. When OFF, intensity decays (faster than the ramp-up), the blades coast to a stop, and the word settles back upright. Pure React + Framer Motion — no Three.js, no canvas, no other libraries.
---
## Constants (module scope)
```ts
const WORD = 'RIPPLE'
// Amplitude ramp — 1 unit/sec up, 2.5 units/sec down.
const LEVEL_COUNT = 1
const LEVEL_RAMP = 1.0
const LEVEL_DECAY = 2.5
const MAX_SCALE = 36 // peak feDisplacementMap scale at full intensity
const OSCILLATION_HZ = 0.09 // breathing oscillation frequency
const REST_FREQ = 0.001 // baseFrequency at rest
const HOVER_FREQ = 0.009 // baseFrequency target at full intensity
const RIPPLE_PHASE_RATE = 0.9 // radians/sec the noise morphs at full intensity
// Fan spin — 2π / 0.6s ≈ 10.47 rad/s at full speed.
const FAN_FULL_RATE = (Math.PI * 2) / 0.6
const FAN_REDUCED_RATE = (Math.PI * 2) / 2.0
```
---
## Theme & colors (raw hex, no Tailwind tokens)
Dual theme — the component listens for theme on a `[data-card-theme]` ancestor first (required for isolated card previews), falling back to `document.documentElement.classList.contains('dark')`.
- Dark: bg `#0A0A0A`, fg `#EFEEE6`, fan stroke `#EFEEE6`
- Light: bg `#EFEEE6`, fg `#0A0A0A`, fan stroke `#0A0A0A`
Vignette behind the layout:
- Dark: `radial-gradient(70% 55% at 50% 50%, rgba(239,238,230,0.06) 0%, rgba(10,10,10,0) 70%)`
- Light: `radial-gradient(70% 55% at 50% 50%, rgba(10,10,10,0.05) 0%, rgba(239,238,230,0) 70%)`
Inline `useTheme(ref)` hook:
1. From `ref.current`, walk up to find the closest `[data-card-theme]` ancestor — if found, theme = that attribute.
2. Otherwise, theme = `html.dark` ? 'dark' : 'light'.
3. Attach a `MutationObserver` to every ancestor from the root up to `<html>`, filtered to `['class', 'data-card-theme']`, re-reading on any change. Disconnect all observers on cleanup.
OFF/ON label uses fixed semantic colors (independent of theme): OFF = `#EF4444`, ON = `#22C55E`.
---
## Layout
- Root: `relative min-h-screen w-full overflow-hidden`, `backgroundColor: bg`.
- Vignette: an absolute `inset: 0` div behind everything with `background: vignette` and `pointerEvents: 'none'`.
- Single content wrapper (absolute `inset: 0`): `display: flex; flexDirection: 'row'; alignItems: 'center'; justifyContent: 'center'; gap: 'clamp(14px, 3vw, 32px)'`. Fan and text sit on one row at every viewport — never stack into a column.
---
## Fan icon (left of the word)
Wrap the SVG in a `<div role="button" aria-label="Toggle fan" aria-pressed={fanOn} tabIndex={0}>` with column flex layout (icon above label, gap 4) and `flexShrink: 0`, `cursor: 'pointer'`, `outline: 'none'`, `userSelect: 'none'`, `touchAction: 'manipulation'`. Click and Space/Enter both toggle.
SVG: `viewBox="0 0 100 100"`, `width: 'clamp(56px, 11vw, 112px)'` (height matches), `transform: 'perspective(220px) rotateY(38deg)'` so the propeller appears tilted away from the viewer. Inside:
- Three concentric grille circles at `r = 38` (stroke 2, opacity 0.85), `r = 30` (stroke 1, opacity 0.35), `r = 22` (stroke 1, opacity 0.25), all using `fanStroke`, no fill.
- A blade group `<g>` with `transformBox: 'view-box'`, `transformOrigin: '50px 50px'`, `willChange: 'transform'`. Inside it, three petal paths rendered via `[0, 120, 240].map((angle) => <path d="M50 50 C 56 34, 64 28, 70 26 C 66 34, 60 42, 50 50 Z" fill={fanStroke} opacity="0.9" transform={\`rotate(${angle} 50 50)\`} />)`.
- Center hub (drawn AFTER blades so it sits on top): `<circle cx="50" cy="50" r="4.5" fill={fanStroke} />` then `<circle cx="50" cy="50" r="1.6" fill={bg} />`.
Label below the SVG: a `<span>` reading `'ON'` or `'OFF'`, system sans, fontWeight 700, fontSize 11, letterSpacing `0.18em`, color = green when on / red when off, transition `color 0.4s, opacity 0.4s`.
---
## Word (right of the fan)
`<svg viewBox="0 0 500 180" preserveAspectRatio="xMidYMid meet" role="img" aria-label={WORD}>` with `width: 'min(55vw, 500px)', height: 'auto', maxHeight: '70vh', overflow: 'visible'`.
Inside `<defs>`, one filter:
```tsx
<filter id={filterId} x="-15%" y="-15%" width="130%" height="130%">
<feTurbulence ref={turbRef} type="fractalNoise" baseFrequency={REST_FREQ} numOctaves={1} seed={4} result="noise" />
<feDisplacementMap ref={dispRef} in="SourceGraphic" in2="noise" scale={0} xChannelSelector="R" yChannelSelector="G" />
</filter>
```
The filter id must be unique per instance — use `useId()` and replace `':'` with `'-'` to keep it valid for `url(#…)` references. `numOctaves={1}` is intentional (a single octave gives the smooth liquid look — higher counts get noisy and chattery).
Then `<g filter={\`url(#${filterId})\`} fill={fg}>` containing one `<text x="250" y="140" textAnchor="middle">RIPPLE</text>` with system sans (`'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif'`), fontWeight 900, fontSize 140, letterSpacing `-0.04em`, `userSelect: 'none'`.
The outer `<svg>` itself has a `textSvgRef` — its CSS transform is `skewX(...)` (see animation loop).
---
## State + animation loop
Refs:
- `turbRef` (SVGFETurbulenceElement), `dispRef` (SVGFEDisplacementMapElement), `bladesRef` (SVGGElement), `textSvgRef` (SVGSVGElement).
- `levelRef` (number, 0..LEVEL_COUNT), `fanAngleRef` (radians), `ripplePhaseRef` (radians), `lastTimeRef` (number | null).
- `fanOn` state (boolean) + a `fanOnRef` mirrored via `useEffect` so the RAF callback always reads the current value.
- `reducedMotion` state from `window.matchMedia('(prefers-reduced-motion: reduce)')` with a `change` listener.
`useAnimationFrame((t) => {...})`:
1. `dt = lastTimeRef.current == null ? 0 : Math.max(0, Math.min(0.05, (t - lastTimeRef.current) / 1000))`. Cap dt to 50 ms so a tab-switch doesn't catapult the simulation.
2. If `fanOnRef.current`: `levelRef.current = min(LEVEL_COUNT, levelRef.current + LEVEL_RAMP * dt)`. Else: `levelRef.current = max(0, levelRef.current - LEVEL_DECAY * dt)`.
3. `intensity = levelRef.current / LEVEL_COUNT` (0..1).
4. `secs = t / 1000`; `breath = sin(secs * 2π * OSCILLATION_HZ)`.
5. `peakScale = reducedMotion ? 14 : MAX_SCALE`. `scale = intensity * peakScale`.
6. `freq = max(0.002, REST_FREQ + (HOVER_FREQ - REST_FREQ) * intensity + 0.004 * breath * intensity)`.
7. Advance noise phase: `ripplePhaseRef.current += intensity * RIPPLE_PHASE_RATE * dt`. Let `p = ripplePhaseRef.current`.
8. Compute live X/Y baseFrequencies that morph continuously while ON:
- `liveFreqX = max(0.002, freq + 0.007 * sin(p) * intensity)`
- `liveFreqY = max(0.002, 0.028 + 0.012 * sin(p * 1.4 + 1) * intensity)`
- Two offset sine waves on X and Y keep the ripple alive without any texture-tile boundary jump.
9. Imperatively: `turb.setAttribute('baseFrequency', \`${liveFreqX} ${liveFreqY}\`)` and `disp.setAttribute('scale', String(scale))`.
10. Skew: `skew = reducedMotion ? 0 : intensity * 13` (degrees). `textSvgRef.current.style.transform = \`skewX(${skew}deg)\``.
11. Blade spin: `rate = reducedMotion ? FAN_REDUCED_RATE : FAN_FULL_RATE`; `fanAngleRef.current += rate * intensity * dt`; wrap > 2π. `bladesRef.current.style.transform = \`rotate(${fanAngleRef.current * 180 / Math.PI}deg)\``.
All filter attribute updates and transforms are written imperatively — no JSX state for animation values, no useState for per-frame data. This avoids React re-render churn at 60fps.
---
## Reduced motion
If `(prefers-reduced-motion: reduce)`:
- `peakScale` clamps to 14 (instead of 36) so the displacement is gentler.
- `skew` is forced to 0 — the word never tilts.
- Blade rate falls to one revolution every 2 seconds (vs ~0.6s).
The fan toggle still works; the ramp/decay still happens — only peak amplitudes are reduced.
---
## Mobile
Single row at every breakpoint — never stack into a column. Fan size, text size, and gap all use `clamp()` so the layout fluidly compresses. The text SVG width is `min(55vw, 500px)`, so even at 320 px viewport the word and fan both fit on one line.
---
## Cleanup
- Disconnect every theme `MutationObserver` on the ancestor chain on unmount.
- Remove the reduced-motion `change` listener.
Single file, default export `RippleType`. Must work copy-pasted into any project with Tailwind v4 + Framer Motion.