← Loaders

Fishbowl

<FishbowlLoader /> A goldfish swimming laps of its bowl.

Animated · Source · Markdown

Loading…
Theme
Usage
<FishbowlLoader
  size={96}
/>
Props and theme tokens
PropTypeDefaultDescription
themePartial<FishbowlLoaderTheme>—Colour overrides. Keys listed under theme tokens.
sizenumber64Width and height in pixels.
labelstring"Loading…"Announced to screen readers through role="status".
pausedbooleanfalseFreezes the animation.
classNamestring—Applied to the root element (an inline-flex span).
styleCSSProperties—Merged into the root element style, after theme variables.
Theme keyCSS variableDefault
glass--glass#b9d8ea
water--water#d8ecf6
fish--fish#f08a3c
fin--fin#f6b26b
weed--weed#6cbf6a
pebbles--pebbles#c9b79a
bubbles--bubbles#ffffff
Source files

Copy into components/loaders/. The shared files are needed once for all loaders.

components/loaders/fishbowl-loader.tsx
// <FishbowlLoader>: a goldfish swimming laps of a round bowl, turning at each side, while bubbles
// rise, the weed sways and the water's surface ripples.

import { useId } from 'react';
import { LoaderFrame, type LoaderProps } from './loader';
import styles from './fishbowl-loader.module.css';

export const fishbowlLoaderTheme = {
  glass: '#b9d8ea',
  water: '#d8ecf6',
  fish: '#f08a3c',
  fin: '#f6b26b',
  weed: '#6cbf6a',
  pebbles: '#c9b79a',
  bubbles: '#ffffff',
};
export type FishbowlLoaderTheme = typeof fishbowlLoaderTheme;

/** A goldfish swimming laps of a round bowl. */
export function FishbowlLoader(props: LoaderProps<FishbowlLoaderTheme>) {
  const id = `qs${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`;
  return (
    <LoaderFrame
      {...props}
      defaultTheme={fishbowlLoaderTheme}
      art={
        <>
          <defs>
            {/* The water: the bowl's disc below a surface line */}
            <clipPath id={`${id}water`}>
              <path d="M10 40 H90 V100 H10 Z" />
            </clipPath>
            <clipPath id={`${id}bowl`}>
              <circle cx="50" cy="55" r="36" />
            </clipPath>
          </defs>

          <g clipPath={`url(#${id}bowl)`}>
            <g clipPath={`url(#${id}water)`}>
              <rect x="0" y="0" width="100" height="100" fill="var(--water)" />
            </g>
            <g className={styles.surface}>
              <path d="M6 40 q6 -2 12 0 t12 0 t12 0 t12 0 t12 0 t12 0 t12 0 t12 0" fill="none" stroke="var(--glass)" strokeWidth="1.6" />
            </g>

            {/* Pebbles and weed on the bottom */}
            <g fill="var(--pebbles)">
              <ellipse cx="30" cy="88" rx="10" ry="5" />
              <ellipse cx="46" cy="90" rx="9" ry="5" />
              <ellipse cx="62" cy="89" rx="10" ry="5" />
              <ellipse cx="74" cy="86" rx="7" ry="4" />
            </g>
            <g transform="translate(68 86)">
              <path className={styles.weed} d="M0 0 C-5 -10 5 -18 0 -28 C-4 -34 2 -38 0 -42" fill="none" stroke="var(--weed)" strokeWidth="3.4" strokeLinecap="round" />
            </g>
            <g transform="translate(32 86)">
              <path className={`${styles.weed} ${styles.weed2}`} d="M0 0 C4 -8 -4 -14 0 -22" fill="none" stroke="var(--weed)" strokeWidth="3" strokeLinecap="round" />
            </g>

            {/* Bubbles from the weed */}
            <g fill="none" stroke="var(--bubbles)" strokeWidth="1.2">
              {[0, 1, 2].map(i => (
                <g key={i} transform={`translate(${66 + i * 3} 60)`}>
                  <circle className={styles.bubble} style={{ animationDelay: `${-i * 0.6}s` }} r={1.6 + i * 0.5} />
                </g>
              ))}
            </g>

            {/* Fish: swims across and back, turning (a squeeze through zero width) at each side */}
            <g className={styles.swim}>
              <g className={styles.turn}>
                <g className={styles.wiggle}>
                  <path className={styles.tail} d="M-9 0 L-18 -7 Q-15 0 -18 7 Z" fill="var(--fin)" />
                  <path d="M-10 0 C-6 -8 8 -8 12 0 C8 8 -6 8 -10 0 Z" fill="var(--fish)" />
                  <path d="M-2 -6 Q2 -11 6 -6 Z" fill="var(--fin)" />
                  <circle cx="7" cy="-1.5" r="1.4" fill="#2b2b2b" />
                </g>
              </g>
            </g>
          </g>

          {/* Glass: outline, rim and a highlight */}
          <circle cx="50" cy="55" r="36" fill="none" stroke="var(--glass)" strokeWidth="3" />
          <path d="M30 22 Q50 16 70 22" fill="none" stroke="var(--glass)" strokeWidth="4" strokeLinecap="round" />
          <path d="M24 48 Q24 34 34 28" fill="none" stroke="#ffffff" strokeWidth="3" strokeLinecap="round" opacity="0.8" />
        </>
      }
    />
  );
}
components/loaders/fishbowl-loader.module.css
/* One 3s lap: the fish swims right (0–42%), turns (42–50%), swims left (50–92%) and turns back.
   The turn is a squeeze through zero width, timed with the ends of the swim. */
.swim { animation: swim 3s ease-in-out infinite; }
.turn { animation: turn 3s ease-in-out infinite; }
.wiggle { animation: wiggle 0.5s ease-in-out infinite alternate; }
.tail {
  transform-box: fill-box;
  transform-origin: 100% 50%;
  animation: tail 0.25s ease-in-out infinite alternate;
}

.weed {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: weed 2.2s ease-in-out infinite alternate;
}
.weed2 { animation-delay: -1.1s; }

.bubble {
  opacity: 0;
  animation: bubble 1.8s ease-in infinite;
}

.surface { animation: lap 2s linear infinite; }

@keyframes swim {
  0%, 100% { transform: translate(30px, 60px); }
  21%      { transform: translate(50px, 56px); }
  42%, 50% { transform: translate(70px, 60px); }
  71%      { transform: translate(50px, 64px); }
  92%      { transform: translate(30px, 60px); }
}
@keyframes turn {
  0%, 42%   { transform: scaleX(1); }
  46%       { transform: scaleX(0.1); }
  50%, 92%  { transform: scaleX(-1); }
  96%       { transform: scaleX(-0.1); }
  100%      { transform: scaleX(1); }
}
@keyframes wiggle {
  from { transform: rotate(-3deg); }
  to   { transform: rotate(3deg); }
}
@keyframes tail {
  from { transform: skewY(-12deg); }
  to   { transform: skewY(12deg); }
}
@keyframes weed {
  from { transform: skewX(-8deg); }
  to   { transform: skewX(8deg); }
}
@keyframes bubble {
  0%   { transform: translate(0, 0); opacity: 0; }
  15%  { opacity: 0.9; }
  85%  { opacity: 0.6; }
  100% { transform: translate(-2px, -22px); opacity: 0; }
}
@keyframes lap { to { transform: translateX(-24px); } }
components/loaders/loader.tsx
// Shared frame for every loader: theming, sizing and an accessible status label.
// Loaders are pure server components: no client JavaScript.

import type { CSSProperties, ReactNode } from 'react';
import styles from './loader.module.css';

/** Props every loader accepts. `T` is the loader's theme (colour tokens). */
export type LoaderProps<T> = {
  /** Override any of the loader's colours. */
  theme?: Partial<T>;
  /** Width and height in pixels (default 64). */
  size?: number;
  /** Text announced to screen readers (default "Loading…"). */
  label?: string;
  /** Freeze the animation. */
  paused?: boolean;
  className?: string;
  style?: CSSProperties;
};

// waterTop → --water-top, leaf1 → --leaf-1
const toVar = (key: string) => `--${key.replace(/([A-Z])/g, '-$1').replace(/(\d+)/g, '-$1').toLowerCase()}`;

type LoaderFrameProps<T extends Record<string, string>> = LoaderProps<T> & {
  defaultTheme: T;
  /** SVG content drawn in a 100×100 box. */
  art: ReactNode;
};

export function LoaderFrame<T extends Record<string, string>>({
  defaultTheme,
  art,
  theme,
  size = 64,
  label = 'Loading…',
  paused,
  className,
  style,
}: LoaderFrameProps<T>) {
  const vars: Record<string, string> = {};
  for (const [key, value] of Object.entries({ ...defaultTheme, ...theme })) {
    if (value) vars[toVar(key)] = value;
  }
  const classes = [styles.root, paused && styles.paused, className].filter(Boolean).join(' ');

  return (
    <span role="status" className={classes} style={{ ...vars, ...style } as CSSProperties}>
      <svg className={styles.art} width={size} height={size} viewBox="0 0 100 100" aria-hidden="true" focusable="false">
        {art}
      </svg>
      <span className={styles.label}>{label}</span>
    </span>
  );
}
components/loaders/loader.module.css
.root {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  line-height: 0;
}

.art {
  display: block;
  overflow: visible;
}

/* Visible to screen readers only. */
.label {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

.paused * { animation-play-state: paused !important; }

/* A loader that stops moving looks broken, so reduced motion swaps the
   choreography for a slow fade that still reads as "working". */
@media (prefers-reduced-motion: reduce) {
  .art * { animation: none !important; }
  .art { animation: breathe 2.4s ease-in-out infinite; }
}

@keyframes breathe {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.45; }
}