Ripple TypeSVG text animation warped by a turbulence filter.
'use client'
// npm install framer-motion react
import React, { useEffect, useId, useRef, useState } from 'react'
import { useAnimationFrame } from 'framer-motion'
// Swap to customise.
const WORD = 'RIPPLE'
// 3-level amplitude ramp — 1 level/sec up, 2.5/sec down.
const LEVEL_COUNT = 1
const LEVEL_RAMP = 1.0
const LEVEL_DECAY = 2.5
const MAX_SCALE = 36
const OSCILLATION_HZ = 0.09
const REST_FREQ = 0.001
const HOVER_FREQ = 0.009
const RIPPLE_PHASE_RATE = 0.9 // radians/sec the noise morphs at full intensity
// Fan spin — target radians/sec for full-speed rotation. 2π / 0.6s ≈ 10.47 rad/s.
const FAN_FULL_RATE = (Math.PI * 2) / 0.6
const FAN_REDUCED_RATE = (Math.PI * 2) / 2.0
function useTheme(ref: React.RefObject<HTMLElement | null>) {
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
useEffect(() => {
const element = ref.current
if (!element) return
const read = () => {
const scope = element.closest('[data-card-theme]') as HTMLElement | null
if (scope) {
setTheme(scope.dataset.cardTheme === 'dark' ? 'dark' : 'light')
return
}
setTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light')
}
read()
const observers: MutationObserver[] = []
let current: HTMLElement | null = element
while (current) {
const o = new MutationObserver(read)
o.observe(current, { attributes: true, attributeFilter: ['class', 'data-card-theme'] })
observers.push(o)
current = current.parentElement
}
return () => observers.forEach((o) => o.disconnect())
}, [ref])
return { theme }
}
export default function RippleType() {
const rootRef = useRef<HTMLDivElement>(null)
const { theme } = useTheme(rootRef)
const isDark = theme === 'dark'
const bg = isDark ? '#0A0A0A' : '#EFEEE6'
const fg = isDark ? '#EFEEE6' : '#0A0A0A'
const fanStroke = isDark ? '#EFEEE6' : '#0A0A0A'
const vignette = isDark
? 'radial-gradient(70% 55% at 50% 50%, rgba(239,238,230,0.06) 0%, rgba(10,10,10,0) 70%)'
: 'radial-gradient(70% 55% at 50% 50%, rgba(10,10,10,0.05) 0%, rgba(239,238,230,0) 70%)'
const uid = useId().replace(/:/g, '-')
const filterId = `ripple-${uid}`
const turbRef = useRef<SVGFETurbulenceElement>(null)
const dispRef = useRef<SVGFEDisplacementMapElement>(null)
const bladesRef = useRef<SVGGElement>(null)
const [reducedMotion, setReducedMotion] = useState(false)
useEffect(() => {
if (typeof window === 'undefined') return
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
const update = () => setReducedMotion(mq.matches)
update()
mq.addEventListener('change', update)
return () => mq.removeEventListener('change', update)
}, [])
// Fan state.
const [fanOn, setFanOn] = useState(false)
const fanOnRef = useRef(false)
useEffect(() => { fanOnRef.current = fanOn }, [fanOn])
// levelRef: 0..LEVEL_COUNT, advances 1 unit/sec when ON, decays when OFF.
// intensity = levelRef / LEVEL_COUNT drives displacement + blade speed.
const levelRef = useRef(0)
const fanAngleRef = useRef(0)
const ripplePhaseRef = useRef(0)
const lastTimeRef = useRef<number | null>(null)
const textSvgRef = useRef<SVGSVGElement>(null)
useAnimationFrame((t) => {
const turb = turbRef.current
const disp = dispRef.current
const blades = bladesRef.current
if (!turb || !disp) return
const last = lastTimeRef.current
const dt = last == null ? 0 : Math.max(0, Math.min(0.05, (t - last) / 1000))
lastTimeRef.current = t
// Ramp level up (1 level/sec) or decay (2.5 levels/sec).
if (fanOnRef.current) {
levelRef.current = Math.min(LEVEL_COUNT, levelRef.current + LEVEL_RAMP * dt)
} else {
levelRef.current = Math.max(0, levelRef.current - LEVEL_DECAY * dt)
}
const intensity = levelRef.current / LEVEL_COUNT // 0..1
// Breathing oscillation — alive and continuous, scales with intensity.
const secs = t / 1000
const breath = Math.sin(secs * Math.PI * 2 * OSCILLATION_HZ)
const peakScale = reducedMotion ? 14 : MAX_SCALE
const scale = intensity * peakScale
const freq = Math.max(0.002,
REST_FREQ + (HOVER_FREQ - REST_FREQ) * intensity + 0.004 * breath * intensity
)
// Advance phase so the noise pattern morphs continuously while the fan is on.
// Two offset sine waves on X and Y freq keep the ripple alive without any
// texture boundary — no stopping, no jump.
ripplePhaseRef.current += intensity * RIPPLE_PHASE_RATE * dt
const p = ripplePhaseRef.current
const liveFreqX = Math.max(0.002, freq + 0.007 * Math.sin(p) * intensity)
const liveFreqY = Math.max(0.002, 0.028 + 0.012 * Math.sin(p * 1.4 + 1) * intensity)
turb.setAttribute('baseFrequency', `${liveFreqX} ${liveFreqY}`)
disp.setAttribute('scale', String(scale))
// Skew — ramps to a fixed lean as intensity builds, stays there while the
// fan is on. Returns to 0 only when fan turns off and intensity decays.
const textSvgEl = textSvgRef.current
if (textSvgEl) {
const skew = reducedMotion ? 0 : intensity * 13
textSvgEl.style.transform = `skewX(${skew}deg)`
}
// Blade spin — also tied to intensity so it accelerates with the ripple.
if (blades) {
const rate = reducedMotion ? FAN_REDUCED_RATE : FAN_FULL_RATE
fanAngleRef.current += rate * intensity * dt
if (fanAngleRef.current > Math.PI * 2) fanAngleRef.current -= Math.PI * 2
blades.style.transform = `rotate(${(fanAngleRef.current * 180) / Math.PI}deg)`
}
})
const toggleFan = () => setFanOn((v) => !v)
const onKey = (e: React.KeyboardEvent<SVGSVGElement>) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault()
toggleFan()
}
}
// Fan icon — 100x100 viewBox, centered on (50,50).
const fan = (
<div
role="button"
aria-label="Toggle fan"
aria-pressed={fanOn}
tabIndex={0}
onClick={toggleFan}
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleFan() } }}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4,
cursor: 'pointer',
outline: 'none',
flexShrink: 0,
userSelect: 'none',
touchAction: 'manipulation',
}}
>
<svg
viewBox="0 0 100 100"
style={{
display: 'block',
transform: 'perspective(220px) rotateY(38deg)',
width: 'clamp(56px, 11vw, 112px)',
height: 'clamp(56px, 11vw, 112px)',
}}
>
{/* Outer grille */}
<circle
cx="50"
cy="50"
r="38"
fill="none"
stroke={fanStroke}
strokeWidth="2"
opacity="0.85"
/>
<circle
cx="50"
cy="50"
r="30"
fill="none"
stroke={fanStroke}
strokeWidth="1"
opacity="0.35"
/>
<circle
cx="50"
cy="50"
r="22"
fill="none"
stroke={fanStroke}
strokeWidth="1"
opacity="0.25"
/>
{/* Blades — 3 blades at 0°, 120°, 240°, rotate as a group around the
hub. `transform-box: view-box` tells the browser to interpret
transform-origin in the SVG's viewBox coords, so `50px 50px` is
the centre. The blades' own `rotate(angle 50 50)` positions each
petal; the group's CSS `rotate(...)` spins the whole propeller. */}
<g
ref={bladesRef}
style={{
transformBox: 'view-box',
transformOrigin: '50px 50px',
willChange: 'transform',
}}
>
{[0, 120, 240].map((angle) => (
<path
key={angle}
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)`}
/>
))}
</g>
{/* Center hub (drawn after blades so it sits on top) */}
<circle cx="50" cy="50" r="4.5" fill={fanStroke} />
<circle cx="50" cy="50" r="1.6" fill={bg} />
</svg>
<span
style={{
fontFamily:
'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
fontWeight: 700,
fontSize: 11,
letterSpacing: '0.18em',
color: fanOn ? '#22C55E' : '#EF4444',
opacity: 1,
transition: 'color 0.4s, opacity 0.4s',
}}
>
{fanOn ? 'ON' : 'OFF'}
</span>
</div>
)
const textSvg = (
<svg
ref={textSvgRef}
role="img"
aria-label={WORD}
viewBox="0 0 500 180"
preserveAspectRatio="xMidYMid meet"
style={{
width: 'min(55vw, 500px)',
height: 'auto',
maxHeight: '70vh',
overflow: 'visible',
}}
>
<defs>
<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>
</defs>
<g filter={`url(#${filterId})`} fill={fg}>
<text
x="250"
y="140"
textAnchor="middle"
style={{
fontFamily:
'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
fontWeight: 900,
fontSize: 140,
letterSpacing: '-0.04em',
userSelect: 'none',
}}
>
{WORD}
</text>
</g>
</svg>
)
return (
<div
ref={rootRef}
className="relative min-h-screen w-full overflow-hidden"
style={{ backgroundColor: bg }}
>
{/* Vignette */}
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
background: vignette,
pointerEvents: 'none',
}}
/>
{/* Single wrapper — fills the full area, centers fan + text both ways */}
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 'clamp(14px, 3vw, 32px)',
}}
>
{fan}
{textSvg}
</div>
</div>
)
}
Add to your project
One command adds this component to your project.
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-typeFor dark mode, add the dark class to your <html> element:
<html class="dark">Install with AI Canvas MCP
With AI Canvas MCP, your AI knows every component we ship. Ask for “a navigation component from AI Canvas” inside Claude Code, Codex, or Cursor and it can suggest you a few options, then install the one you like. Less typing, lower token cost, modern way to build.
Get MCPAbout Ripple Type
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.


