← Loaders

Toast

<ToastLoader /> A toaster popping two slices up; one does a flip.

Animated · Source · Markdown

Loading…
Theme
Usage
<ToastLoader
  size={96}
/>
Props and theme tokens
PropTypeDefaultDescription
themePartial<ToastLoaderTheme>—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
toaster--toaster#9cc9e3
toasterShade--toaster-shade#7fb2d2
bread--bread#e9b96e
crust--crust#b8713a
spark--spark#f2c14e
face--face#3d4a57
Source files

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

components/loaders/toast-loader.tsx
// <ToastLoader>: a toaster that crouches, squeezes its eyes shut, then pops two slices of toast up.
// One slice flips head over heels before both drop back in.

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

export const toastLoaderTheme = {
  toaster: '#9cc9e3',
  toasterShade: '#7fb2d2',
  bread: '#e9b96e',
  crust: '#b8713a',
  spark: '#f2c14e',
  face: '#3d4a57',
};
export type ToastLoaderTheme = typeof toastLoaderTheme;

function Slice({ x, className }: { x: number; className: string }) {
  return (
    <g className={className}>
      <path d={`M${x} 56 V36 c0 -7 4 -9 9 -9 c5 0 9 2 9 9 V56 Z`} fill="var(--crust)" />
      <path d={`M${x + 2.5} 56 V37 c0 -5 3 -6.5 6.5 -6.5 c3.5 0 6.5 1.5 6.5 6.5 V56 Z`} fill="var(--bread)" />
    </g>
  );
}

/** A toaster that crouches, then pops two slices of toast up; one flips before they drop back in. */
export function ToastLoader(props: LoaderProps<ToastLoaderTheme>) {
  return (
    <LoaderFrame
      {...props}
      defaultTheme={toastLoaderTheme}
      art={
        <>
          <g fill="none" stroke="var(--spark)" strokeWidth="3" strokeLinecap="round">
            <path className={styles.spark} d="M24 26 l-6 -5 M50 16 v-7 M76 26 l6 -5" />
          </g>
          <Slice x={31} className={styles.slice} />
          <Slice x={51} className={`${styles.slice} ${styles.slice2}`} />
          <g className={styles.toaster}>
            <rect x="20" y="50" width="60" height="36" rx="12" fill="var(--toaster)" />
            <rect x="20" y="78" width="60" height="8" rx="4" fill="var(--toaster-shade)" />
            <rect x="30" y="48" width="18" height="5" rx="2.5" fill="var(--toaster-shade)" />
            <rect x="52" y="48" width="18" height="5" rx="2.5" fill="var(--toaster-shade)" />
            <g className={styles.eyes} fill="var(--face)">
              <ellipse cx="41" cy="66" rx="2.2" ry="2.6" />
              <ellipse cx="59" cy="66" rx="2.2" ry="2.6" />
            </g>
            <path d="M47 71 Q50 73.5 53 71" fill="none" stroke="var(--face)" strokeWidth="1.6" strokeLinecap="round" />
            <ellipse cx="35" cy="70.5" rx="3" ry="1.8" fill="var(--spark)" opacity="0.45" />
            <ellipse cx="65" cy="70.5" rx="3" ry="1.8" fill="var(--spark)" opacity="0.45" />
          </g>
          <g className={styles.lever}>
            <rect x="80" y="58" width="9" height="4" rx="2" fill="var(--toaster-shade)" />
          </g>
          <ellipse cx="50" cy="92" rx="30" ry="3" fill="var(--toaster-shade)" opacity="0.4" />
        </>
      }
    />
  );
}
components/loaders/toast-loader.module.css
/* One 1.6s loop: the toaster crouches (eyes squeezed shut), the slices pop up, one flips
   head over heels, and both drop back in. */
.toaster {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: squash 1.6s ease-in-out infinite;
}
.slice {
  animation: pop 1.6s cubic-bezier(0.3, 0, 0.3, 1) infinite;
}
.slice2 {
  transform-box: fill-box;
  transform-origin: 50% 50%;
  animation-name: flip;
  animation-delay: 0.06s;
}
.eyes {
  transform-box: fill-box;
  transform-origin: 50% 50%;
  animation: squint 1.6s ease-in-out infinite;
}
.lever { animation: lever 1.6s ease-in-out infinite; }
.spark {
  transform-box: fill-box;
  transform-origin: center;
  opacity: 0;
  animation: spark 1.6s ease-out infinite;
}

@keyframes squash {
  0%, 18%, 100% { transform: scale(1, 1); }
  10% { transform: scale(1.05, 0.93); }
  24% { transform: scale(0.97, 1.04); }
}
@keyframes pop {
  0%, 14% { transform: translateY(0); }
  32% { transform: translateY(-26px); }
  52% { transform: translateY(-20px); }
  72%, 100% { transform: translateY(0); }
}
@keyframes flip {
  0%, 14%   { transform: translateY(0) rotate(0deg); }
  32%       { transform: translateY(-30px) rotate(200deg); }
  50%       { transform: translateY(-22px) rotate(360deg); }
  72%, 100% { transform: translateY(0) rotate(360deg); }
}
@keyframes squint {
  0%, 4%, 22%, 100% { transform: scaleY(1); }
  10%, 16%          { transform: scaleY(0.2); }
}
@keyframes lever {
  0%, 10% { transform: translateY(8px); }
  18%, 70% { transform: translateY(0); }
  90%, 100% { transform: translateY(8px); }
}
@keyframes spark {
  0%, 24% { opacity: 0; transform: scale(0.6); }
  34% { opacity: 1; transform: scale(1); }
  50%, 100% { opacity: 0; transform: scale(1.2); }
}
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; }
}