Jellyfish
React + Tailwind
import { useId } from 'react';
import type { CSSProperties } from 'react';
const css = `
/* A quick squeeze pushes it up; the bell relaxes slowly while it sinks back. */
@keyframes loader-jellyfish-swim {
0% { transform: translateY(0); animation-timing-function: cubic-bezier(0.2, 0.7, 0.3, 1); }
28% { transform: translateY(-10px); animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
100% { transform: translateY(0); }
}
@keyframes loader-jellyfish-pulse {
0% { transform: scale(1.04, 0.94); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1); }
18% { transform: scale(0.8, 1.14); animation-timing-function: cubic-bezier(0.4, 0, 0.4, 1); }
60%, 100% { transform: scale(1.04, 0.94); }
}
/* A wave travels down each tentacle: the wavy line slides down one wavelength under a fixed clip. */
@keyframes loader-jellyfish-wave { from { transform: translateY(-24px); } to { transform: translateY(0); } }
@keyframes loader-jellyfish-snow { 0% { transform: translateY(0); opacity: 0; } 20%, 70% { opacity: 0.8; } 100% { transform: translateY(-40px); opacity: 0; } }
.loader-jellyfish { position: relative; width: 90px; height: 126px; }
.loader-jellyfish svg { width: 100%; height: 100%; overflow: visible; }
.loader-jellyfish__body { animation: loader-jellyfish-swim 2.4s infinite; }
.loader-jellyfish__bell { transform-origin: 40px 40px; animation: loader-jellyfish-pulse 2.4s infinite; }
.loader-jellyfish__dome { fill: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 30%, transparent); stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.3; stroke-linejoin: round; }
.loader-jellyfish__gonad { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 1; opacity: 0.45; }
.loader-jellyfish__tentacle { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1; stroke-linecap: round; animation: loader-jellyfish-wave var(--dur) linear infinite var(--d); }
.loader-jellyfish__tentacle--arm { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 2.6; }
.loader-jellyfish__snow { fill: light-dark(#6d7179, #7d8087); opacity: 0; animation: loader-jellyfish-snow 3.6s linear infinite var(--d); }
@media (prefers-reduced-motion: reduce) {
.loader-jellyfish__body, .loader-jellyfish__bell, .loader-jellyfish__tentacle, .loader-jellyfish__snow { animation: none; }
}
`;
// Two full waves above the clip so the slide never shows a loose end.
const wave = (x: number, a: number) => `M${x} 16q${a} 6 0 12t0 12t0 12t0 12t0 12t0 12t0 12t0 12t0 12`;
const TENTACLES = [
{ x: 20, a: 2.5, dur: 1.4, d: 0 },
{ x: 27, a: 3, dur: 1.7, d: -0.4 },
{ x: 53, a: 3, dur: 1.6, d: -0.9 },
{ x: 60, a: 2.5, dur: 1.3, d: -0.2 },
{ x: 35, a: 4, dur: 2.2, d: -0.6, arm: true },
{ x: 45, a: -4, dur: 2, d: -1.2, arm: true },
{ x: 40, a: 3.5, dur: 2.4, d: -0.3, arm: true },
];
const SNOW = [
{ x: 10, y: 96, d: 0 },
{ x: 68, y: 80, d: -1.2 },
{ x: 16, y: 60, d: -2.4 },
{ x: 72, y: 104, d: -0.6 },
{ x: 62, y: 40, d: -3 },
];
export function JellyfishLoader() {
const clip = `loader-jellyfish-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
return (
<>
<style>
{css}
</style>
<div className="loader-jellyfish" role="status" aria-label="Loading">
<svg viewBox="0 0 80 112" aria-hidden="true">
<defs>
<clipPath id={clip}>
<rect x="0" y="38" width="80" height="62" />
</clipPath>
</defs>
{SNOW.map((s) => (
<circle key={s.x} className="loader-jellyfish__snow" cx={s.x} cy={s.y} r="0.9" style={{ '--d': `${s.d}s` } as CSSProperties} />
))}
<g className="loader-jellyfish__body">
<g clipPath={`url(#${clip})`}>
{TENTACLES.map((t) => (
<path
key={t.x}
className={`loader-jellyfish__tentacle${t.arm ? ' loader-jellyfish__tentacle--arm' : ''}`}
d={wave(t.x, t.a)}
style={{ '--dur': `${t.dur}s`, '--d': `${t.d}s` } as CSSProperties}
/>
))}
</g>
<g className="loader-jellyfish__bell">
<path className="loader-jellyfish__dome" d="M15 40Q15 12 40 12Q65 12 65 40Q59 44 53 40Q46.5 45 40 40Q33.5 45 27 40Q21 44 15 40Z" />
<path className="loader-jellyfish__gonad" d="M21 38Q21 19 40 18Q59 19 59 38M31 38Q31 24 40 22M49 38Q49 24 40 22" />
</g>
</g>
</svg>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="loader-jellyfish" role="status" aria-label="Loading">
<svg viewBox="0 0 80 112" aria-hidden="true">
<defs>
<clipPath id="loader-jellyfish-clip">
<rect x="0" y="38" width="80" height="62"></rect>
</clipPath>
</defs>
<circle class="loader-jellyfish__snow" cx="10" cy="96" r="0.9" style="--d:0s"></circle>
<circle class="loader-jellyfish__snow" cx="68" cy="80" r="0.9" style="--d:-1.2s"></circle>
<circle class="loader-jellyfish__snow" cx="16" cy="60" r="0.9" style="--d:-2.4s"></circle>
<circle class="loader-jellyfish__snow" cx="72" cy="104" r="0.9" style="--d:-0.6s"></circle>
<circle class="loader-jellyfish__snow" cx="62" cy="40" r="0.9" style="--d:-3s"></circle>
<g class="loader-jellyfish__body">
<g clip-path="url(#loader-jellyfish-clip)">
<path class="loader-jellyfish__tentacle" d="M20 16q2.5 6 0 12t0 12t0 12t0 12t0 12t0 12t0 12t0 12t0 12" style="--dur:1.4s;--d:0s"></path>
<path class="loader-jellyfish__tentacle" d="M27 16q3 6 0 12t0 12t0 12t0 12t0 12t0 12t0 12t0 12t0 12" style="--dur:1.7s;--d:-0.4s"></path>
<path class="loader-jellyfish__tentacle" d="M53 16q3 6 0 12t0 12t0 12t0 12t0 12t0 12t0 12t0 12t0 12" style="--dur:1.6s;--d:-0.9s"></path>
<path class="loader-jellyfish__tentacle" d="M60 16q2.5 6 0 12t0 12t0 12t0 12t0 12t0 12t0 12t0 12t0 12" style="--dur:1.3s;--d:-0.2s"></path>
<path class="loader-jellyfish__tentacle loader-jellyfish__tentacle--arm" d="M35 16q4 6 0 12t0 12t0 12t0 12t0 12t0 12t0 12t0 12t0 12" style="--dur:2.2s;--d:-0.6s"></path>
<path class="loader-jellyfish__tentacle loader-jellyfish__tentacle--arm" d="M45 16q-4 6 0 12t0 12t0 12t0 12t0 12t0 12t0 12t0 12t0 12" style="--dur:2s;--d:-1.2s"></path>
<path class="loader-jellyfish__tentacle loader-jellyfish__tentacle--arm" d="M40 16q3.5 6 0 12t0 12t0 12t0 12t0 12t0 12t0 12t0 12t0 12" style="--dur:2.4s;--d:-0.3s"></path>
</g>
<g class="loader-jellyfish__bell">
<path class="loader-jellyfish__dome" d="M15 40Q15 12 40 12Q65 12 65 40Q59 44 53 40Q46.5 45 40 40Q33.5 45 27 40Q21 44 15 40Z"></path>
<path class="loader-jellyfish__gonad" d="M21 38Q21 19 40 18Q59 19 59 38M31 38Q31 24 40 22M49 38Q49 24 40 22"></path>
</g>
</g>
</svg>
</div>CSS
/* A quick squeeze pushes it up; the bell relaxes slowly while it sinks back. */
@keyframes loader-jellyfish-swim {
0% { transform: translateY(0); animation-timing-function: cubic-bezier(0.2, 0.7, 0.3, 1); }
28% { transform: translateY(-10px); animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
100% { transform: translateY(0); }
}
@keyframes loader-jellyfish-pulse {
0% { transform: scale(1.04, 0.94); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1); }
18% { transform: scale(0.8, 1.14); animation-timing-function: cubic-bezier(0.4, 0, 0.4, 1); }
60%, 100% { transform: scale(1.04, 0.94); }
}
/* A wave travels down each tentacle: the wavy line slides down one wavelength under a fixed clip. */
@keyframes loader-jellyfish-wave { from { transform: translateY(-24px); } to { transform: translateY(0); } }
@keyframes loader-jellyfish-snow { 0% { transform: translateY(0); opacity: 0; } 20%, 70% { opacity: 0.8; } 100% { transform: translateY(-40px); opacity: 0; } }
.loader-jellyfish { position: relative; width: 90px; height: 126px; }
.loader-jellyfish svg { width: 100%; height: 100%; overflow: visible; }
.loader-jellyfish__body { animation: loader-jellyfish-swim 2.4s infinite; }
.loader-jellyfish__bell { transform-origin: 40px 40px; animation: loader-jellyfish-pulse 2.4s infinite; }
.loader-jellyfish__dome { fill: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 30%, transparent); stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.3; stroke-linejoin: round; }
.loader-jellyfish__gonad { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 1; opacity: 0.45; }
.loader-jellyfish__tentacle { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1; stroke-linecap: round; animation: loader-jellyfish-wave var(--dur) linear infinite var(--d); }
.loader-jellyfish__tentacle--arm { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 2.6; }
.loader-jellyfish__snow { fill: light-dark(#6d7179, #7d8087); opacity: 0; animation: loader-jellyfish-snow 3.6s linear infinite var(--d); }
@media (prefers-reduced-motion: reduce) {
.loader-jellyfish__body, .loader-jellyfish__bell, .loader-jellyfish__tentacle, .loader-jellyfish__snow { animation: none; }
}Goal: Create a "Jellyfish" loader.
Visual behavior: A jellyfish pulses its bell to push itself up, then drifts back down while waves ripple along its tentacles.
Technique:
- A quick squeeze narrows and stretches the bell and lifts the body; the bell relaxes slowly while it sinks back.
- Each tentacle is a long wavy path under a fixed clip, slid down by exactly one wavelength on a loop, so waves travel along it. Specks of marine snow drift up behind.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the jellyfish hangs still.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Jellyfish" loader.
Visual behavior: A jellyfish pulses its bell to push itself up, then drifts back down while waves ripple along its tentacles.
Technique:
- A quick squeeze narrows and stretches the bell and lifts the body; the bell relaxes slowly while it sinks back.
- Each tentacle is a long wavy path under a fixed clip, slid down by exactly one wavelength on a loop, so waves travel along it. Specks of marine snow drift up behind.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the jellyfish hangs still.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A jellyfish pulses its bell to push itself up, then drifts back down while waves ripple along its tentacles.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- continuous, loop, morph
- Techniques
- keyframes, clip path, svg
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the jellyfish hangs still.