Goo
React + Tailwind
import { useId } from 'react';
const T = 2;
// Two discs share one spot and pull apart. A blur plus a hard alpha cut (the
// filter below) melts them into one shape while they are close, so the neck
// between them stretches and snaps like a drop of liquid.
const css = `
.loader-goo { --ink: light-dark(#373a40, #f1f2f3); display: inline-block; font-size: 112px; width: 1em; height: 1em; }
.loader-goo svg { display: block; width: 100%; height: 100%; }
.loader-goo__turn { transform-box: view-box; transform-origin: 50px 50px; animation: loader-goo-turn ${T}s cubic-bezier(0.65, 0, 0.35, 1) infinite; }
.loader-goo__blob { fill: var(--ink); transform-box: view-box; transform-origin: 50px 50px; animation: loader-goo-split ${T}s infinite; }
.loader-goo__blob + .loader-goo__blob { --dir: 1; }
@keyframes loader-goo-split {
0% { transform: translateX(0) scale(1); animation-timing-function: cubic-bezier(0.5, 0, 0.3, 1); }
45%, 55% { transform: translateX(calc(var(--dir, -1) * 21px)) scale(0.74); animation-timing-function: cubic-bezier(0.6, 0, 0.35, 1); }
100% { transform: translateX(0) scale(1); }
}
@keyframes loader-goo-turn { to { transform: rotate(180deg); } }
@media (prefers-reduced-motion: reduce) {
.loader-goo__turn, .loader-goo__blob { animation: none; }
.loader-goo__blob { transform: translateX(calc(var(--dir, -1) * 12px)) scale(0.82); }
}
`;
export function Preview() {
const id = useId();
return (
<>
<style href="kit-loaders-goo" precedence="kit">
{css}
</style>
<div className="loader-goo" role="status" aria-label="Loading">
<svg viewBox="0 0 100 100" aria-hidden="true">
<defs>
<filter id={id} x="0" y="0" width="100" height="100" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
<feGaussianBlur stdDeviation="5" />
<feColorMatrix values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7" />
</filter>
</defs>
<g filter={`url(#${id})`}>
<g className="loader-goo__turn">
<circle className="loader-goo__blob" cx="50" cy="50" r="17" />
<circle className="loader-goo__blob" cx="50" cy="50" r="17" />
</g>
</g>
</svg>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="loader-goo" role="status" aria-label="Loading">
<svg viewBox="0 0 100 100" aria-hidden="true">
<defs>
<filter id="loader-goo" x="0" y="0" width="100" height="100" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="5"></feGaussianBlur>
<feColorMatrix values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"></feColorMatrix>
</filter>
</defs>
<g filter="url(#loader-goo)">
<g class="loader-goo__turn">
<circle class="loader-goo__blob" cx="50" cy="50" r="17"></circle>
<circle class="loader-goo__blob" cx="50" cy="50" r="17"></circle>
</g>
</g>
</svg>
</div>CSS
.loader-goo { --ink: light-dark(#373a40, #f1f2f3); display: inline-block; font-size: 112px; width: 1em; height: 1em; }
.loader-goo svg { display: block; width: 100%; height: 100%; }
.loader-goo__turn { transform-box: view-box; transform-origin: 50px 50px; animation: loader-goo-turn 2s cubic-bezier(0.65, 0, 0.35, 1) infinite; }
.loader-goo__blob { fill: var(--ink); transform-box: view-box; transform-origin: 50px 50px; animation: loader-goo-split 2s infinite; }
.loader-goo__blob + .loader-goo__blob { --dir: 1; }
@keyframes loader-goo-split {
0% { transform: translateX(0) scale(1); animation-timing-function: cubic-bezier(0.5, 0, 0.3, 1); }
45%, 55% { transform: translateX(calc(var(--dir, -1) * 21px)) scale(0.74); animation-timing-function: cubic-bezier(0.6, 0, 0.35, 1); }
100% { transform: translateX(0) scale(1); }
}
@keyframes loader-goo-turn { to { transform: rotate(180deg); } }
@media (prefers-reduced-motion: reduce) {
.loader-goo__turn, .loader-goo__blob { animation: none; }
.loader-goo__blob { transform: translateX(calc(var(--dir, -1) * 12px)) scale(0.82); }
}Goal: Create a "Goo" loader.
Visual behavior: A drop of goo pinches into two, spins a half turn while they stretch apart, and pulls back into one.
Technique:
- Pure CSS: every motion is a keyframe animation, so the plain HTML version needs no script.
- Everything is sized in em from one font-size on the root, so changing that one value scales the whole loader.
- Two discs share one spot and pull apart. An SVG filter blurs them and then cuts the alpha hard, which melts them into one shape while they are close.
- As they separate the neck between them thins and snaps, then reforms as they come back, like a drop of liquid.
- The pair turns a half turn each cycle, fastest while they are apart, so the split happens on a new axis every time.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside it is aria-hidden. Under prefers-reduced-motion the drop shows half pinched, still joined.
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 "Goo" loader.
Visual behavior: A drop of goo pinches into two, spins a half turn while they stretch apart, and pulls back into one.
Technique:
- Pure CSS: every motion is a keyframe animation, so the plain HTML version needs no script.
- Everything is sized in em from one font-size on the root, so changing that one value scales the whole loader.
- Two discs share one spot and pull apart. An SVG filter blurs them and then cuts the alpha hard, which melts them into one shape while they are close.
- As they separate the neck between them thins and snaps, then reforms as they come back, like a drop of liquid.
- The pair turns a half turn each cycle, fastest while they are apart, so the split happens on a new axis every time.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside it is aria-hidden. Under prefers-reduced-motion the drop shows half pinched, still joined.
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 drop of goo pinches into two, spins a half turn while they stretch apart, and pulls back into one.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- continuous, loop, morph
- Techniques
- svg, svg filter, keyframes
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The root has role="status" and aria-label="Loading"; everything inside it is aria-hidden. Under prefers-reduced-motion the drop shows half pinched, still joined.