# TeaLoader

> A teabag on a string dunks into a cup of tea on a 2.4s loop: rings spread across the surface as it goes under, it lifts out swinging, and a drip falls back with a smaller ring. Two wisps of steam curl up beside it.

- Page: https://www.boatui.dev/loaders/tea
- Source: https://github.com/wi11s/boatui/blob/main/components/loaders/tea-loader.tsx
- Animated. Generate: ~1,272 tokens (source size, single pass). Import: ~16 tokens.

## Install

All loaders: `npx degit wi11s/boatui/components/loaders components/loaders`

Only this one: copy the shared files (`loader.tsx`, `loader.module.css`) and `tea-loader.tsx` and `tea-loader.module.css` into `components/loaders/`. Shared files are in https://www.boatui.dev/llms-full.txt.

## Usage

```tsx
import { TeaLoader } from '@/components/loaders/tea-loader';

export function Saving() {
  return <TeaLoader size={48} label="Saving your changes…" />;
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `theme` | `Partial<TeaLoaderTheme>` | — | Colour overrides. Keys listed under theme tokens. |
| `size` | `number` | 64 | Width and height in pixels. |
| `label` | `string` | "Loading…" | Announced to screen readers through role="status". |
| `paused` | `boolean` | false | Freezes the animation. |
| `className` | `string` | — | Applied to the root element (an inline-flex span). |
| `style` | `CSSProperties` | — | Merged into the root element style, after theme variables. |

## Theme tokens

| Key | CSS variable | Default |
| --- | --- | --- |
| `cup` | `--cup` | `#9cc9e3` |
| `cupShade` | `--cup-shade` | `#7fb2d2` |
| `tea` | `--tea` | `#b8713a` |
| `ripple` | `--ripple` | `#dca06a` |
| `steam` | `--steam` | `#a7b4bd` |
| `bag` | `--bag` | `#ead6b0` |
| `tag` | `--tag` | `#f2c14e` |

## Source

`components/loaders/tea-loader.tsx`

```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`

```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; }
}
```
