Flask
import { useId } from 'react';
const css = `
@keyframes loader-flask-level {
0% { transform: translateY(58px); }
45%, 60% { transform: translateY(8px); }
100% { transform: translateY(58px); }
}
@keyframes loader-flask-wave { to { transform: translateX(-40px); } }
@keyframes loader-flask-bubble {
0% { transform: translateY(0) scale(0.6); opacity: 0; }
20% { opacity: 1; }
100% { transform: translateY(-34px) scale(1); opacity: 0; }
}
.loader-flask { display: block; width: 80px; height: 100px; overflow: visible; }
.loader-flask__glass { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.6; stroke-linejoin: round; }
.loader-flask__rim { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.6; stroke-linecap: round; }
.loader-flask__level { animation: loader-flask-level 4s cubic-bezier(0.45, 0, 0.55, 1) infinite; }
.loader-flask__wave { animation: loader-flask-wave 1.2s linear infinite; }
.loader-flask__wave--back { animation-duration: 1.9s; animation-direction: reverse; }
.loader-flask__liquid { fill: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 75%, transparent); }
.loader-flask__liquid--back { fill: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 30%, transparent); }
.loader-flask__bubble {
fill: none;
stroke: light-dark(#fbfcfd, #121417);
stroke-width: 1;
transform-box: fill-box;
transform-origin: center;
opacity: 0;
animation: loader-flask-bubble 1.6s ease-in var(--d) infinite;
}
@media (prefers-reduced-motion: reduce) {
.loader-flask__level { animation: none; transform: translateY(30px); }
.loader-flask__wave, .loader-flask__bubble { animation: none; }
}
`;
// Round-bottom flask: a neck from y 6 to 40, then a circle (centre 40,66) round the bottom.
const GLASS = 'M32 6V40A27.2 27.2 0 1 0 48 40V6';
const INSIDE = 'M33 6V40.6A26.2 26.2 0 1 0 47 40.6V6Z';
// Two wave periods of 40 wide, so sliding by one period loops seamlessly.
const wave = (amp: number) => `M-40 0Q-30 ${-amp} -20 0T0 0T20 0T40 0T60 0T80 0T100 0T120 0T140 0V100H-40Z`;
export function FlaskLoader() {
const clip = `flask-${useId().replace(/:/g, '')}`;
return (
<>
<style>
{css}
</style>
<svg className="loader-flask" viewBox="0 0 80 100" role="status" aria-label="Loading">
<clipPath id={clip}>
<path d={INSIDE} />
</clipPath>
<g clipPath={`url(#${clip})`}>
<g className="loader-flask__level">
<g transform="translate(0 38)">
<g className="loader-flask__wave loader-flask__wave--back">
<path className="loader-flask__liquid loader-flask__liquid--back" d={wave(3)} transform="translate(-10 -1)" />
</g>
<g className="loader-flask__wave">
<path className="loader-flask__liquid" d={wave(2.5)} />
</g>
{[
{ x: 32, d: '0s' },
{ x: 44, d: '0.6s' },
{ x: 50, d: '1.1s' },
].map((b) => (
<circle
key={b.x}
className="loader-flask__bubble"
cx={b.x}
cy={44}
r={2}
style={{ '--d': b.d } as React.CSSProperties}
/>
))}
</g>
</g>
</g>
<path className="loader-flask__glass" d={GLASS} />
<path className="loader-flask__rim" d="M29 6H51" />
</svg>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initLoaderFlask(el) on the mounted element instead.
<svg class="loader-flask" viewBox="0 0 80 100" role="status" aria-label="Loading">
<clipPath id="flask-_R_0_">
<path d="M33 6V40.6A26.2 26.2 0 1 0 47 40.6V6Z"></path>
</clipPath>
<g clip-path="url(#flask-_R_0_)">
<g class="loader-flask__level">
<g transform="translate(0 38)">
<g class="loader-flask__wave loader-flask__wave--back">
<path class="loader-flask__liquid loader-flask__liquid--back" d="M-40 0Q-30 -3 -20 0T0 0T20 0T40 0T60 0T80 0T100 0T120 0T140 0V100H-40Z" transform="translate(-10 -1)"></path>
</g>
<g class="loader-flask__wave">
<path class="loader-flask__liquid" d="M-40 0Q-30 -2.5 -20 0T0 0T20 0T40 0T60 0T80 0T100 0T120 0T140 0V100H-40Z"></path>
</g>
<circle class="loader-flask__bubble" cx="32" cy="44" r="2" style="--d:0s"></circle>
<circle class="loader-flask__bubble" cx="44" cy="44" r="2" style="--d:0.6s"></circle>
<circle class="loader-flask__bubble" cx="50" cy="44" r="2" style="--d:1.1s"></circle>
</g>
</g>
</g>
<path class="loader-flask__glass" d="M32 6V40A27.2 27.2 0 1 0 48 40V6"></path>
<path class="loader-flask__rim" d="M29 6H51"></path>
</svg>@keyframes loader-flask-level {
0% { transform: translateY(58px); }
45%, 60% { transform: translateY(8px); }
100% { transform: translateY(58px); }
}
@keyframes loader-flask-wave { to { transform: translateX(-40px); } }
@keyframes loader-flask-bubble {
0% { transform: translateY(0) scale(0.6); opacity: 0; }
20% { opacity: 1; }
100% { transform: translateY(-34px) scale(1); opacity: 0; }
}
.loader-flask { display: block; width: 80px; height: 100px; overflow: visible; }
.loader-flask__glass { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.6; stroke-linejoin: round; }
.loader-flask__rim { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.6; stroke-linecap: round; }
.loader-flask__level { animation: loader-flask-level 4s cubic-bezier(0.45, 0, 0.55, 1) infinite; }
.loader-flask__wave { animation: loader-flask-wave 1.2s linear infinite; }
.loader-flask__wave--back { animation-duration: 1.9s; animation-direction: reverse; }
.loader-flask__liquid { fill: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 75%, transparent); }
.loader-flask__liquid--back { fill: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 30%, transparent); }
.loader-flask__bubble {
fill: none;
stroke: light-dark(#fbfcfd, #121417);
stroke-width: 1;
transform-box: fill-box;
transform-origin: center;
opacity: 0;
animation: loader-flask-bubble 1.6s ease-in var(--d) infinite;
}
@media (prefers-reduced-motion: reduce) {
.loader-flask__level { animation: none; transform: translateY(30px); }
.loader-flask__wave, .loader-flask__bubble { animation: none; }
}var loaderFlaskCount = 0;
function initLoaderFlask(root) {
var clip = root.querySelector('clipPath');
var group = root.querySelector('g[clip-path]');
if (!clip || !group) return;
// The clip id must be unique per copy on the page.
var id = 'loader-flask-' + ++loaderFlaskCount;
clip.id = id;
group.setAttribute('clip-path', 'url(#' + id + ')');
}
document.querySelectorAll('.loader-flask').forEach(initLoaderFlask);Goal: Create a "Flask" loader.
Visual behavior: A round-bottom flask fills with a sloshing liquid as bubbles rise through it, holds full for a moment, then drains, over and over.
Technique:
- The flask is one SVG path (a neck into a circle); a slightly smaller copy is a clipPath for the liquid.
- The liquid is two wave paths of two periods each that slide sideways by exactly one period (front 1.2s, back 1.9s reversed), so the surface sloshes seamlessly.
- A group around them rises and falls on a 4s keyframe for the fill level; three bubbles rise and fade with staggered delays.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the flask sits half full and still.
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 "Flask" loader.
Visual behavior: A round-bottom flask fills with a sloshing liquid as bubbles rise through it, holds full for a moment, then drains, over and over.
Technique:
- The flask is one SVG path (a neck into a circle); a slightly smaller copy is a clipPath for the liquid.
- The liquid is two wave paths of two periods each that slide sideways by exactly one period (front 1.2s, back 1.9s reversed), so the surface sloshes seamlessly.
- A group around them rises and falls on a 4s keyframe for the fill level; three bubbles rise and fade with staggered delays.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the flask sits half full and still.
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 round-bottom flask fills with a sloshing liquid as bubbles rise through it, holds full for a moment, then drains, over and over.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- continuous, loop, fill
- Techniques
- svg, keyframes, clip path
- 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 flask sits half full and still.