Skeleton
6s on one page-wide clock. When loaded, the same threshold sweeps once and swaps placeholder for content cell by cell, the front cells in the accent.
// Skeleton: a loading placeholder that shimmers in 1-bit ordered dither instead of a gradient.
// A 120px band sweeps every 1.6s on one page-wide clock; inside it 3px cells switch on by a
// Bayer 4×4 threshold that peaks at the band's centre. When loaded, the same threshold sweeps
// once (400ms) and swaps placeholder for content cell by cell, the front cells in the accent.
// Wrap several skeletons in <SkeletonGroup> to have one band (and one reveal) cross them all.
import { createContext, useContext, useEffect, useLayoutEffect, useRef, type CSSProperties, type ReactNode, type RefObject } from 'react';
import { createLoop } from '../shared/spring';
import { useOnScreen, useReducedMotion } from '../shared/chart-kit';
import { ACCENTS, resolveAccent, rgba, type Accent } from '../shared/accent';
import { bayer, clamp } from '../status-kit/kit';
import '../shared/ember.css';
import './skeleton.css';
export interface SkeletonProps {
shape?: 'text' | 'block' | 'circle';
/** Text only. The last line is 60% wide. */
lines?: number;
width?: number | string;
height?: number | string;
loaded?: boolean;
/** The real content, shown when `loaded`. It shares the placeholder's box, so nothing shifts. */
children?: ReactNode;
accent?: Accent;
className?: string;
style?: CSSProperties;
}
const CELL = 3;
const BAND = 120;
const PERIOD_MS = 1600;
const REVEAL_MS = 400;
const RAMP = 48; // width of the dithered front during the reveal
const BASE_A = 0.06;
const LIT_A = (0.13 - BASE_A) / (1 - BASE_A); // drawn over the 6% shape, the cell reads as 13%
const ScopeContext = createContext<RefObject<HTMLElement | null> | null>(null);
/** Shares one sweep (shimmer band and reveal front) across every Skeleton inside it. */
export function SkeletonGroup({ children, className, style }: { children: ReactNode; className?: string; style?: CSSProperties }) {
const ref = useRef<HTMLDivElement>(null);
return (
<ScopeContext.Provider value={ref}>
<div ref={ref} className={className} style={style}>
{children}
</div>
</ScopeContext.Provider>
);
}
interface ShapeRect {
x: number;
y: number;
w: number;
h: number;
round: boolean;
}
const px = (v: number | string | undefined) => (typeof v === 'number' ? `${v}px` : v);
export function Skeleton({
shape = 'text',
lines = 3,
width,
height,
loaded = false,
children,
accent: accentProp,
className,
style,
}: SkeletonProps) {
const accent = resolveAccent(accentProp);
const reduced = useReducedMotion();
const scopeRef = useContext(ScopeContext);
const rootRef = useRef<HTMLDivElement>(null);
const phRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const onScreen = useOnScreen(rootRef);
const reducedRef = useRef(reduced);
reducedRef.current = reduced;
const s = useRef({
shapes: [] as ShapeRect[],
mode: (loaded ? 'done' : 'loading') as 'loading' | 'reveal' | 'done',
revealAt: 0,
accent,
});
s.current.accent = accent;
const measureShapes = () => {
const ph = phRef.current;
if (!ph) return;
s.current.shapes = Array.from(ph.querySelectorAll<HTMLElement>('.esk-shape')).map((el) => ({
x: el.offsetLeft,
y: el.offsetTop,
w: el.offsetWidth,
h: el.offsetHeight,
round: el.classList.contains('esk-circle'),
}));
};
/** Applies the clip that shows content where the front has passed and the placeholder elsewhere. */
const setClips = (contentClip: string | null, phClip: string | null) => {
const c = contentRef.current;
const p = phRef.current;
if (c) c.style.clipPath = contentClip ?? '';
if (p) p.style.clipPath = phClip ?? '';
};
const frame = (now: number): boolean => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return false;
const st = s.current;
const dpr = window.devicePixelRatio || 1;
const rr = root.getBoundingClientRect();
const scopeEl = scopeRef?.current ?? root;
const sr = scopeEl === root ? rr : scopeEl.getBoundingClientRect();
const W = rr.width;
const H = rr.height;
// Canvas origin on a whole device pixel so 3px cells land on device pixels.
const devL = Math.floor(rr.left * dpr);
const devT = Math.floor(rr.top * dpr);
const fx = rr.left * dpr - devL;
const fy = rr.top * dpr - devT;
const bw = Math.ceil(W * dpr + fx) + 1;
const bh = Math.ceil(H * dpr + fy) + 1;
if (canvas.width !== bw) canvas.width = bw;
if (canvas.height !== bh) canvas.height = bh;
canvas.style.width = `${bw / dpr}px`;
canvas.style.height = `${bh / dpr}px`;
canvas.style.transform = `translate(${-fx / dpr}px, ${-fy / dpr}px)`;
const ctx = canvas.getContext('2d')!;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, bw, bh);
// Scope coordinates (CSS px) → this canvas (device px).
const ox = sr.left - rr.left;
const oy = sr.top - rr.top;
const toDevX = (sx: number) => Math.round((sx + ox) * dpr + fx);
const toDevY = (sy: number) => Math.round((sy + oy) * dpr + fy);
// Cell range covering this skeleton, in scope cells.
const k0 = Math.floor(-ox / CELL);
const k1 = Math.ceil((W - ox) / CELL);
const j0 = Math.floor(-oy / CELL);
const j1 = Math.ceil((H - oy) / CELL);
const clipShapes = () => {
ctx.beginPath();
for (const r of st.shapes) {
const x = r.x * dpr + fx;
const y = r.y * dpr + fy;
if (r.round) {
ctx.moveTo(x + r.w * dpr, y + (r.h * dpr) / 2);
ctx.ellipse(x + (r.w * dpr) / 2, y + (r.h * dpr) / 2, (r.w * dpr) / 2, (r.h * dpr) / 2, 0, 0, Math.PI * 2);
} else {
ctx.roundRect(x, y, r.w * dpr, r.h * dpr, 4 * dpr);
}
}
ctx.clip();
};
if (st.mode === 'loading') {
const t = (now % PERIOD_MS) / PERIOD_MS;
const span = sr.width + BAND;
const bx = -BAND / 2 + t * span;
const ka = Math.max(k0, Math.floor((bx - BAND / 2) / CELL));
const kb = Math.min(k1, Math.ceil((bx + BAND / 2) / CELL));
if (kb >= ka) {
ctx.save();
clipShapes();
ctx.fillStyle = `rgba(255,255,255,${LIT_A})`;
for (let k = ka; k <= kb; k++) {
const i = 1 - Math.abs(k * CELL + CELL / 2 - bx) / (BAND / 2);
if (i <= 0) continue;
const x0 = toDevX(k * CELL);
const x1 = toDevX((k + 1) * CELL);
for (let j = j0; j <= j1; j++) {
if (i > bayer(k, j)) {
const y0 = toDevY(j * CELL);
ctx.fillRect(x0, y0, x1 - x0, toDevY((j + 1) * CELL) - y0);
}
}
}
ctx.restore();
}
return true;
}
if (st.mode === 'reveal') {
const p = clamp((now - st.revealAt) / REVEAL_MS, 0, 1);
const front = -RAMP + p * (sr.width + RAMP * 2);
// Local (CSS) coordinates of a scope cell edge, for the clip paths.
const lx = (k: number) => +(k * CELL + ox).toFixed(2);
const ly = (j: number) => +(j * CELL + oy).toFixed(2);
let shown = '';
let hidden = '';
ctx.save();
clipShapes();
ctx.fillStyle = rgba(ACCENTS[st.accent].base, 1);
for (let j = j0; j <= j1; j++) {
let runStart = k0;
let runOn: boolean | null = null;
const flush = (end: number) => {
if (runOn === null) return;
const seg = `M${lx(runStart)} ${ly(j)}H${lx(end)}V${ly(j + 1)}H${lx(runStart)}Z`;
if (runOn) shown += seg;
else hidden += seg;
};
for (let k = k0; k <= k1; k++) {
const r = clamp((front - (k * CELL + CELL / 2)) / RAMP, 0, 1);
const thr = bayer(k, j);
const on = r > thr;
if (on && r < 1 && r - thr < 0.3) {
const x0 = toDevX(k * CELL);
const y0 = toDevY(j * CELL);
ctx.fillRect(x0, y0, toDevX((k + 1) * CELL) - x0, toDevY((j + 1) * CELL) - y0);
}
if (on !== runOn) {
flush(k);
runStart = k;
runOn = on;
}
}
flush(k1 + 1);
}
ctx.restore();
if (p >= 1) {
st.mode = 'done';
setClips(null, null);
root.dataset.state = 'done';
ctx.clearRect(0, 0, bw, bh);
return false;
}
setClips(`path('${shown || 'M0 0Z'}')`, `path('${hidden || 'M0 0Z'}')`);
return true;
}
return false;
};
const loopRef = useRef<ReturnType<typeof createLoop> | null>(null);
if (!loopRef.current) loopRef.current = createLoop((_dt, now) => frame(now));
useLayoutEffect(() => {
measureShapes();
const ro = new ResizeObserver(() => {
measureShapes();
if (s.current.mode === 'loading' && !reducedRef.current) frame(performance.now());
});
if (rootRef.current) ro.observe(rootRef.current);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shape, lines]);
// Loading ↔ loaded.
const firstRun = useRef(true);
useLayoutEffect(() => {
const st = s.current;
const root = rootRef.current!;
const loop = loopRef.current!;
const wasFirst = firstRun.current;
firstRun.current = false;
if (!loaded) {
st.mode = 'loading';
root.dataset.state = 'loading';
setClips(null, null);
} else if (st.mode === 'loading') {
if (wasFirst || reduced || !onScreen) {
st.mode = 'done';
root.dataset.state = 'done';
setClips(null, null);
} else {
st.mode = 'reveal';
st.revealAt = performance.now();
root.dataset.state = 'reveal';
}
}
const animate = !reduced && onScreen && st.mode !== 'done';
if (animate) loop.wake();
else {
loop.stop();
if (st.mode === 'reveal') {
st.mode = 'done';
root.dataset.state = 'done';
setClips(null, null);
}
const c = canvasRef.current;
c?.getContext('2d')?.clearRect(0, 0, c.width, c.height);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loaded, reduced, onScreen]);
useEffect(() => () => loopRef.current?.stop(), []);
const boxW = width ?? (shape === 'circle' ? 40 : '100%');
const boxH = height ?? (shape === 'circle' ? (typeof boxW === 'number' ? boxW : 40) : shape === 'block' ? 120 : undefined);
const n = Math.max(1, Math.round(lines) || 1);
return (
<div
ref={rootRef}
className={className ? `ember esk ${className}` : 'ember esk'}
data-accent={accent}
data-shape={shape}
data-state={loaded ? 'done' : 'loading'}
aria-busy={!loaded}
style={{ width: px(boxW), ...(shape === 'text' ? {} : { height: px(boxH) }), ...style }}
>
<div ref={phRef} className="esk-ph" aria-hidden="true">
{shape === 'text' ? (
Array.from({ length: n }, (_, i) => (
<div key={i} className="esk-shape esk-line" style={i === n - 1 ? { width: '60%' } : undefined} />
))
) : (
<div className={shape === 'circle' ? 'esk-shape esk-circle' : 'esk-shape esk-block'} />
)}
</div>
<div ref={contentRef} className="esk-content">
{children}
</div>
<canvas ref={canvasRef} className="esk-canvas" aria-hidden="true" />
</div>
);
}
export default Skeleton;
Customize
circle-text
text
block
012345678903
012345678900123456789001234567890120
Content shares the placeholder's box, so nothing shifts.
Install
npx shadcn@latest add https://obsydeui.com/r/skeleton.jsonUsage
Import it from where the CLI puts it, or copy the files below by hand.
import { Skeleton } from '@/components/obsyde/ember/skeleton/Skeleton';
export function Example() {
return (
<Skeleton />
);
}
Files
Plain React, TypeScript and CSS. No dependencies. The download keeps these paths.
ember/skeleton/Skeleton.tsxcomponentember/skeleton/skeleton-demo.cssstylesember/skeleton/skeleton.cssstylesember/skeleton/SkeletonDemo.tsxdemoember/status-kit/kit.tssharedember/shared/chart-kit.tssharedember/shared/spring.tssharedember/shared/accent.tssharedember/shared/ember.cssshared
API
| Prop | Type | Default |
|---|---|---|
accentAccent | 'ember' | 'volt' | 'arc' | 'ember' |
layoutLayout | 'circle-text' | 'text' | 'block' | 'circle-text' |
linesLines | number | 3 |
heightBlock height | number | 120 |
loadedLoaded | boolean | false |