Jellyfish

No dependencies
Code format
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/loader-jellyfish
Source on GitHub

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.

More loaders

See all
Juggle
Spirit level
Locomotive
Pancake