← Loaders

Tea

<TeaLoader /> A teabag dunking into a cup of tea.

Animated · Source · Markdown

Loading…
Theme
Usage
<TeaLoader
  size={96}
/>
Props and theme tokens
PropTypeDefaultDescription
themePartial<TeaLoaderTheme>—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
cup--cup#9cc9e3
cupShade--cup-shade#7fb2d2
tea--tea#b8713a
ripple--ripple#dca06a
steam--steam#a7b4bd
bag--bag#ead6b0
tag--tag#f2c14e
Source files

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

components/loaders/tea-loader.tsx
// <TeaLoader>: a teabag dunking into a cup of tea. Rings spread across the surface as it goes in,
// it lifts out with a drip that plinks back, and steam curls up beside it.

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

export const teaLoaderTheme = {
  cup: '#9cc9e3',
  cupShade: '#7fb2d2',
  tea: '#b8713a',
  ripple: '#dca06a',
  steam: '#a7b4bd',
  bag: '#ead6b0',
  tag: '#f2c14e',
};
export type TeaLoaderTheme = typeof teaLoaderTheme;

/** A teabag dunking into a cup of tea, with ripples, a drip and curling steam. */
export function TeaLoader(props: LoaderProps<TeaLoaderTheme>) {
  const id = `qs${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`;
  return (
    <LoaderFrame
      {...props}
      defaultTheme={teaLoaderTheme}
      art={
        <>
          <defs>
            {/* Above the tea's surface line: the bag and string vanish as they go under. */}
            <clipPath id={`${id}above`}>
              <rect x="0" y="-20" width="100" height="68" />
            </clipPath>
            <clipPath id={`${id}surface`}>
              <ellipse cx="50" cy="48" rx="26" ry="5" />
            </clipPath>
          </defs>

          <g fill="none" stroke="var(--steam)" strokeWidth="3" strokeLinecap="round">
            {[0, 1].map(i => (
              <g key={i} transform={`translate(${34 + i * 9} 0)`}>
                <path
                  className={styles.steam}
                  style={{ animationDelay: `${-i * 1.2}s` }}
                  d="M0 40 c-4 -4 4 -8 0 -12 c-4 -4 4 -8 0 -12"
                />
              </g>
            ))}
          </g>

          <ellipse cx="50" cy="87" rx="36" ry="6" fill="var(--cup-shade)" />
          <path d="M73 55 c13 0 13 17 0 17" fill="none" stroke="var(--cup)" strokeWidth="5" strokeLinecap="round" />
          <ellipse cx="50" cy="48" rx="28" ry="6" fill="var(--cup-shade)" />
          <ellipse cx="50" cy="48" rx="26" ry="5" fill="var(--tea)" />

          <g clipPath={`url(#${id}surface)`} fill="none" stroke="var(--ripple)" strokeWidth="1.4">
            <ellipse className={styles.ripple} cx="58" cy="48" rx="8" ry="1.8" />
            <ellipse className={`${styles.ripple} ${styles.ripple2}`} cx="58" cy="48" rx="8" ry="1.8" />
            <ellipse className={`${styles.ripple} ${styles.ripple3}`} cx="58" cy="48" rx="6" ry="1.4" />
          </g>

          <g clipPath={`url(#${id}above)`}>
            <g className={styles.bag}>
              <path d="M58 6 V28" stroke="var(--steam)" strokeWidth="1.2" />
              <rect x="54" y="0" width="8" height="8" rx="1.5" fill="var(--tag)" />
              <path d="M52 28 H64 L63 42 Q58 44 53 42 Z" fill="var(--bag)" />
              <path d="M52 28 H64 V31 H52 Z" fill="var(--tag)" opacity="0.6" />
            </g>
            <circle className={styles.drip} cx="58" cy="43" r="1.6" fill="var(--tea)" />
          </g>

          <path d="M22 48 Q22 49 24 50 C30 53 70 53 76 50 Q78 49 78 48 V60 C78 78 66 85 50 85 C34 85 22 78 22 60 Z" fill="var(--cup)" />
          <path d="M28 60 C29 72 36 79 44 81" fill="none" stroke="#ffffff" strokeOpacity="0.45" strokeWidth="2.5" strokeLinecap="round" />
        </>
      }
    />
  );
}
components/loaders/tea-loader.module.css
/* One 2.4s loop: the bag hangs, dunks (rings spread), lifts out, and a drip falls back in. */
.bag {
  transform-box: fill-box;
  transform-origin: 50% 0%;
  animation: dunk 2.4s ease-in-out infinite;
}

.ripple {
  transform-box: fill-box;
  transform-origin: center;
  opacity: 0;
  animation: ripple 2.4s ease-out infinite;
}
.ripple2 { animation-delay: 0.18s; }
/* The drip's smaller ring, late in the loop. */
.ripple3 { animation-name: dripRing; }

.drip {
  opacity: 0;
  animation: drip 2.4s ease-in infinite;
}

/* Steam rises, sways and fades on its own slower beat. */
.steam {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  opacity: 0;
  animation: rise 2.4s ease-out infinite;
}

@keyframes dunk {
  0%, 18%  { transform: translateY(0) rotate(0deg); }
  36%      { transform: translateY(17px) rotate(-3deg); }
  48%      { transform: translateY(15px) rotate(2deg); }
  66%      { transform: translateY(-1px) rotate(1deg); }
  76%, 100% { transform: translateY(0) rotate(0deg); }
}
@keyframes ripple {
  0%, 26% { transform: scale(0.3); opacity: 0; }
  30%     { opacity: 1; }
  62%     { transform: scale(2.2); opacity: 0; }
  100%    { transform: scale(2.2); opacity: 0; }
}
@keyframes drip {
  0%, 72% { transform: translateY(0); opacity: 0; }
  76%     { transform: translateY(0); opacity: 1; }
  88%     { transform: translateY(6px); opacity: 1; }
  90%, 100% { transform: translateY(6px); opacity: 0; }
}
@keyframes dripRing {
  0%, 88% { transform: scale(0.3); opacity: 0; }
  91%     { opacity: 1; }
  100%    { transform: scale(1.5); opacity: 0; }
}
@keyframes rise {
  0%   { transform: translate(0, 6px) scaleY(0.6); opacity: 0; }
  30%  { opacity: 0.9; }
  65%  { transform: translate(-2px, -6px) scaleY(1); opacity: 0.6; }
  100% { transform: translate(2px, -16px) scaleY(1.15); opacity: 0; }
}
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; }
}