Loom
React + Tailwind
const ROWS = 6;
const WARPS = 9;
const GAP = 10; // warp spacing
const ROW = 9; // row height
const W = WARPS * GAP;
const H = ROWS * ROW;
const SLOT = 12; // percent of the cycle each pass takes; rows are woven in 72%, held, then cleared together
// One pass per row, alternating direction like a shuttle: left to right, then back.
const rowKeyframes = Array.from({ length: ROWS }, (_, r) => {
const a = r * SLOT;
const b = a + SLOT;
const from = r % 2 ? 'inset(0 0 0 100%)' : 'inset(0 100% 0 0)';
return `@keyframes loader-loom-r${r} { 0%, ${a}% { clip-path: ${from}; } ${b}%, 88% { clip-path: inset(0 0 0 0); opacity: 1; } 96%, 100% { clip-path: inset(0 0 0 0); opacity: 0; } }`;
}).join('\n ');
const shuttle = Array.from({ length: ROWS }, (_, r) => {
const y = H - (r + 0.5) * ROW;
const [x0, x1] = r % 2 ? [W + 8, -8] : [-8, W + 8];
return `${r * SLOT}% { transform: translate(${x0}px, ${y}px); } ${(r + 1) * SLOT - 0.01}% { transform: translate(${x1}px, ${y}px); }`;
}).join(' ');
const css = `
${rowKeyframes}
@keyframes loader-loom-shuttle { ${shuttle} 72.01%, 100% { transform: translate(${W + 8}px, ${H - ROW / 2}px); opacity: 0; } }
.loader-loom { position: relative; width: ${W}px; height: ${H + 16}px; padding-top: 8px; }
.loader-loom__warp { position: absolute; top: 0; bottom: 0; width: 1.5px; background: light-dark(#6d7179, #7d8087); }
.loader-loom__row { position: absolute; left: -4px; right: -4px; height: ${ROW}px; }
/* Under the warp: the thread's faint continuous line. Over it: the bright stretches between every other warp. */
.loader-loom__row::before { content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 3px; margin-top: -1.5px; background: light-dark(#dbdde1, #2e3035); }
.loader-loom__over { position: absolute; left: 4px; right: 4px; top: 50%; height: 5px; margin-top: -2.5px; background: repeating-linear-gradient(90deg, transparent 0 calc(var(--o) * ${GAP}px + 1.5px), light-dark(#42454c, #e5e6e9) calc(var(--o) * ${GAP}px + 1.5px) calc(var(--o) * ${GAP}px + ${GAP}px), transparent calc(var(--o) * ${GAP}px + ${GAP}px) ${GAP * 2}px); border-radius: 2px; }
.loader-loom__row { animation: var(--anim) 5s linear infinite; }
.loader-loom__warps { position: absolute; left: 0; right: 0; top: 0; bottom: 8px; }
.loader-loom__shuttle { position: absolute; left: 0; top: 8px; width: 14px; height: 5px; margin: -2.5px -7px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 6px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 50%, transparent); animation: loader-loom-shuttle 5s linear infinite; }
.loader-loom__beam { position: absolute; left: -8px; right: -8px; height: 4px; border-radius: 2px; background: light-dark(#cbced3, #393c41); }
@media (prefers-reduced-motion: reduce) {
.loader-loom__row, .loader-loom__shuttle { animation: none; }
.loader-loom__shuttle { display: none; }
}
`;
export function LoomLoader() {
return (
<>
<style>
{css}
</style>
<div className="loader-loom" role="status" aria-label="Loading">
<span className="loader-loom__beam" aria-hidden="true" style={{ top: 2 }} />
{Array.from({ length: ROWS }, (_, r) => (
<span
key={r}
className="loader-loom__row"
aria-hidden="true"
style={{ top: 8 + H - (r + 1) * ROW, '--anim': `loader-loom-r${r}`, zIndex: 0 } as React.CSSProperties}
>
<span className="loader-loom__over" style={{ '--o': r % 2, zIndex: 2 } as React.CSSProperties} />
</span>
))}
<span className="loader-loom__warps" aria-hidden="true" style={{ top: 8, zIndex: 1 }}>
{Array.from({ length: WARPS + 1 }, (_, k) => (
<span key={k} className="loader-loom__warp" style={{ left: k * GAP }} />
))}
</span>
<span className="loader-loom__beam" aria-hidden="true" style={{ top: H + 10 }} />
<span className="loader-loom__shuttle" aria-hidden="true" />
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="loader-loom" role="status" aria-label="Loading">
<span class="loader-loom__beam" aria-hidden="true" style="top:2px"></span>
<span class="loader-loom__row" aria-hidden="true" style="top:53px;--anim:loader-loom-def;z-index:0">
<span class="loader-loom__over" style="--o:0;z-index:2"></span>
</span>
<span class="loader-loom__row" aria-hidden="true" style="top:44px;--anim:loader-loom-def;z-index:0">
<span class="loader-loom__over" style="--o:1;z-index:2"></span>
</span>
<span class="loader-loom__row" aria-hidden="true" style="top:35px;--anim:loader-loom-def;z-index:0">
<span class="loader-loom__over" style="--o:0;z-index:2"></span>
</span>
<span class="loader-loom__row" aria-hidden="true" style="top:26px;--anim:loader-loom-def;z-index:0">
<span class="loader-loom__over" style="--o:1;z-index:2"></span>
</span>
<span class="loader-loom__row" aria-hidden="true" style="top:17px;--anim:loader-loom-def;z-index:0">
<span class="loader-loom__over" style="--o:0;z-index:2"></span>
</span>
<span class="loader-loom__row" aria-hidden="true" style="top:8px;--anim:loader-loom-def;z-index:0">
<span class="loader-loom__over" style="--o:1;z-index:2"></span>
</span>
<span class="loader-loom__warps" aria-hidden="true" style="top:8px;z-index:1">
<span class="loader-loom__warp" style="left:0"></span>
<span class="loader-loom__warp" style="left:10px"></span>
<span class="loader-loom__warp" style="left:20px"></span>
<span class="loader-loom__warp" style="left:30px"></span>
<span class="loader-loom__warp" style="left:40px"></span>
<span class="loader-loom__warp" style="left:50px"></span>
<span class="loader-loom__warp" style="left:60px"></span>
<span class="loader-loom__warp" style="left:70px"></span>
<span class="loader-loom__warp" style="left:80px"></span>
<span class="loader-loom__warp" style="left:90px"></span>
</span>
<span class="loader-loom__beam" aria-hidden="true" style="top:64px"></span>
<span class="loader-loom__shuttle" aria-hidden="true"></span>
</div>CSS
@keyframes loader-loom-r0 { 0%, 0% { clip-path: inset(0 100% 0 0); } 12%, 88% { clip-path: inset(0 0 0 0); opacity: 1; } 96%, 100% { clip-path: inset(0 0 0 0); opacity: 0; } }
@keyframes loader-loom-r1 { 0%, 12% { clip-path: inset(0 0 0 100%); } 24%, 88% { clip-path: inset(0 0 0 0); opacity: 1; } 96%, 100% { clip-path: inset(0 0 0 0); opacity: 0; } }
@keyframes loader-loom-r2 { 0%, 24% { clip-path: inset(0 100% 0 0); } 36%, 88% { clip-path: inset(0 0 0 0); opacity: 1; } 96%, 100% { clip-path: inset(0 0 0 0); opacity: 0; } }
@keyframes loader-loom-r3 { 0%, 36% { clip-path: inset(0 0 0 100%); } 48%, 88% { clip-path: inset(0 0 0 0); opacity: 1; } 96%, 100% { clip-path: inset(0 0 0 0); opacity: 0; } }
@keyframes loader-loom-r4 { 0%, 48% { clip-path: inset(0 100% 0 0); } 60%, 88% { clip-path: inset(0 0 0 0); opacity: 1; } 96%, 100% { clip-path: inset(0 0 0 0); opacity: 0; } }
@keyframes loader-loom-r5 { 0%, 60% { clip-path: inset(0 0 0 100%); } 72%, 88% { clip-path: inset(0 0 0 0); opacity: 1; } 96%, 100% { clip-path: inset(0 0 0 0); opacity: 0; } }
@keyframes loader-loom-shuttle { 0% { transform: translate(-8px, 49.5px); } 11.99% { transform: translate(98px, 49.5px); } 12% { transform: translate(98px, 40.5px); } 23.99% { transform: translate(-8px, 40.5px); } 24% { transform: translate(-8px, 31.5px); } 35.99% { transform: translate(98px, 31.5px); } 36% { transform: translate(98px, 22.5px); } 47.99% { transform: translate(-8px, 22.5px); } 48% { transform: translate(-8px, 13.5px); } 59.99% { transform: translate(98px, 13.5px); } 60% { transform: translate(98px, 4.5px); } 71.99% { transform: translate(-8px, 4.5px); } 72.01%, 100% { transform: translate(98px, 49.5px); opacity: 0; } }
.loader-loom { position: relative; width: 90px; height: 70px; padding-top: 8px; }
.loader-loom__warp { position: absolute; top: 0; bottom: 0; width: 1.5px; background: light-dark(#6d7179, #7d8087); }
.loader-loom__row { position: absolute; left: -4px; right: -4px; height: 9px; }
/* Under the warp: the thread's faint continuous line. Over it: the bright stretches between every other warp. */
.loader-loom__row::before { content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 3px; margin-top: -1.5px; background: light-dark(#dbdde1, #2e3035); }
.loader-loom__over { position: absolute; left: 4px; right: 4px; top: 50%; height: 5px; margin-top: -2.5px; background: repeating-linear-gradient(90deg, transparent 0 calc(var(--o) * 10px + 1.5px), light-dark(#42454c, #e5e6e9) calc(var(--o) * 10px + 1.5px) calc(var(--o) * 10px + 10px), transparent calc(var(--o) * 10px + 10px) 20px); border-radius: 2px; }
.loader-loom__row { animation: var(--anim) 5s linear infinite; }
.loader-loom__warps { position: absolute; left: 0; right: 0; top: 0; bottom: 8px; }
.loader-loom__shuttle { position: absolute; left: 0; top: 8px; width: 14px; height: 5px; margin: -2.5px -7px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 6px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 50%, transparent); animation: loader-loom-shuttle 5s linear infinite; }
.loader-loom__beam { position: absolute; left: -8px; right: -8px; height: 4px; border-radius: 2px; background: light-dark(#cbced3, #393c41); }
@media (prefers-reduced-motion: reduce) {
.loader-loom__row, .loader-loom__shuttle { animation: none; }
.loader-loom__shuttle { display: none; }
}Goal: Create a "Loom" loader.
Visual behavior: A loom weaving: a shuttle carries the weft over and under the warp threads, row by row and back again, until the cloth is woven, then it is cleared and begins again.
Technique:
- Each row is a faint line under the warp threads plus a repeating gradient of bright segments over them, offset by one warp on alternate rows, so the weave goes over and under.
- Each row has its own keyframe that reveals it with clip-path inset, left to right on even rows and right to left on odd ones, in its own slot of the cycle.
- The shuttle is one keyframe with a pair of stops per row, so it runs along each row exactly while that row is revealed.
- All the rows fade out together at the end of the cycle.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the cloth is shown fully woven and the shuttle is hidden.
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 "Loom" loader.
Visual behavior: A loom weaving: a shuttle carries the weft over and under the warp threads, row by row and back again, until the cloth is woven, then it is cleared and begins again.
Technique:
- Each row is a faint line under the warp threads plus a repeating gradient of bright segments over them, offset by one warp on alternate rows, so the weave goes over and under.
- Each row has its own keyframe that reveals it with clip-path inset, left to right on even rows and right to left on odd ones, in its own slot of the cycle.
- The shuttle is one keyframe with a pair of stops per row, so it runs along each row exactly while that row is revealed.
- All the rows fade out together at the end of the cycle.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the cloth is shown fully woven and the shuttle is hidden.
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 loom weaving: a shuttle carries the weft over and under the warp threads, row by row and back again, until the cloth is woven, then it is cleared and begins again.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- continuous, loop, delay stagger
- Techniques
- keyframes, clip path, gradient, stagger
- 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 cloth is shown fully woven and the shuttle is hidden.