Brew
React + Tailwind
const WISPS = [
{ x: 22, d: 0, dur: 2.6 },
{ x: 32, d: -0.9, dur: 3.1 },
{ x: 42, d: -1.7, dur: 2.8 },
];
const css = `
/* A wisp draws up out of the cup, sways and thins away while it rises. */
@keyframes loader-brew-rise {
0% { stroke-dashoffset: 1; opacity: 0; transform: translateY(6px); }
25% { opacity: 0.9; }
60% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -1; opacity: 0; transform: translateY(-10px); }
}
.loader-brew { position: relative; width: 76px; height: 84px; }
.loader-brew svg { width: 100%; height: 100%; overflow: visible; }
.loader-brew__wisp { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2.2; stroke-linecap: round; stroke-dasharray: 1; animation: loader-brew-rise var(--dur) ease-in-out infinite var(--d); }
.loader-brew__cup { fill: light-dark(#dbdde1, #2e3035); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.2; }
.loader-brew__handle { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 3.5; }
.loader-brew__coffee { fill: light-dark(#f3f4f7, #1a1c1f); }
.loader-brew__saucer { fill: light-dark(#ebedf0, #232529); stroke: light-dark(#cbced3, #393c41); stroke-width: 1; }
@media (prefers-reduced-motion: reduce) {
.loader-brew__wisp { animation: none; stroke-dashoffset: 0; opacity: 0.6; }
}
`;
export function BrewLoader() {
return (
<>
<style>
{css}
</style>
<div className="loader-brew" role="status" aria-label="Loading">
<svg viewBox="0 0 76 84" aria-hidden="true">
{WISPS.map((w) => (
<path
key={w.x}
className="loader-brew__wisp"
pathLength={1}
d={`M${w.x} 40C${w.x - 6} 32 ${w.x + 6} 26 ${w.x} 18S${w.x + 5} 8 ${w.x - 1} 2`}
style={{ '--d': `${w.d}s`, '--dur': `${w.dur}s` } as React.CSSProperties}
/>
))}
<path className="loader-brew__handle" d="M58 52C68 52 68 66 56 66" />
<ellipse className="loader-brew__saucer" cx="34" cy="76" rx="30" ry="5" />
<path className="loader-brew__cup" d="M10 46H58L54 68C53 73 49 75 44 75H24C19 75 15 73 14 68Z" />
<ellipse className="loader-brew__coffee" cx="34" cy="46.5" rx="22.5" ry="3" />
</svg>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="loader-brew" role="status" aria-label="Loading">
<svg viewBox="0 0 76 84" aria-hidden="true">
<path class="loader-brew__wisp" pathLength="1" d="M22 40C16 32 28 26 22 18S27 8 21 2" style="--d:0s;--dur:2.6s"></path>
<path class="loader-brew__wisp" pathLength="1" d="M32 40C26 32 38 26 32 18S37 8 31 2" style="--d:-0.9s;--dur:3.1s"></path>
<path class="loader-brew__wisp" pathLength="1" d="M42 40C36 32 48 26 42 18S47 8 41 2" style="--d:-1.7s;--dur:2.8s"></path>
<path class="loader-brew__handle" d="M58 52C68 52 68 66 56 66"></path>
<ellipse class="loader-brew__saucer" cx="34" cy="76" rx="30" ry="5"></ellipse>
<path class="loader-brew__cup" d="M10 46H58L54 68C53 73 49 75 44 75H24C19 75 15 73 14 68Z"></path>
<ellipse class="loader-brew__coffee" cx="34" cy="46.5" rx="22.5" ry="3"></ellipse>
</svg>
</div>CSS
/* A wisp draws up out of the cup, sways and thins away while it rises. */
@keyframes loader-brew-rise {
0% { stroke-dashoffset: 1; opacity: 0; transform: translateY(6px); }
25% { opacity: 0.9; }
60% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -1; opacity: 0; transform: translateY(-10px); }
}
.loader-brew { position: relative; width: 76px; height: 84px; }
.loader-brew svg { width: 100%; height: 100%; overflow: visible; }
.loader-brew__wisp { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2.2; stroke-linecap: round; stroke-dasharray: 1; animation: loader-brew-rise var(--dur) ease-in-out infinite var(--d); }
.loader-brew__cup { fill: light-dark(#dbdde1, #2e3035); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.2; }
.loader-brew__handle { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 3.5; }
.loader-brew__coffee { fill: light-dark(#f3f4f7, #1a1c1f); }
.loader-brew__saucer { fill: light-dark(#ebedf0, #232529); stroke: light-dark(#cbced3, #393c41); stroke-width: 1; }
@media (prefers-reduced-motion: reduce) {
.loader-brew__wisp { animation: none; stroke-dashoffset: 0; opacity: 0.6; }
}Goal: Create a "Brew" loader.
Visual behavior: A cup of coffee on its saucer with three threads of steam curling up and thinning out, each on its own rhythm.
Technique:
- Each wisp is an S-shaped SVG path with pathLength="1", drawn upwards with a dash-offset animation while it rises, then erased from the bottom as it fades.
- Per-wisp --dur and --d custom properties give each one a different length and offset, so the steam never moves in step.
- The cup, handle, coffee and saucer are plain SVG shapes.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the steam is drawn still and faint.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Brew" loader.
Visual behavior: A cup of coffee on its saucer with three threads of steam curling up and thinning out, each on its own rhythm.
Technique:
- Each wisp is an S-shaped SVG path with pathLength="1", drawn upwards with a dash-offset animation while it rises, then erased from the bottom as it fades.
- Per-wisp --dur and --d custom properties give each one a different length and offset, so the steam never moves in step.
- The cup, handle, coffee and saucer are plain SVG shapes.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the steam is drawn still and faint.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A cup of coffee on its saucer with three threads of steam curling up and thinning out, each on its own rhythm.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- continuous, loop, draw, delay stagger
- Techniques
- svg, stroke dashoffset, keyframes, css variables
- 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 steam is drawn still and faint.