Color Field Skeleton
Loading skeleton placeholder: pulsing bars on a Rothko color field that wait in grisaille and glaze the palette color in when switched to colored. Adjust the line count; the pulse is opacity only.
pnpm dlx shadcn add @01orbital/color-field-skeletonimport { cn } from "cn"
import { PAPER_GRAIN } from "@/components/texture/grain-recipes"
// Waits as a grisaille (chroma zero) and glazes the palette's color in over
// ~1.2s when `colored` flips true. The opacity-only pulse is reduced-motion safe.
const PULSE_CSS = `
@keyframes color-field-skeleton-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 0.85; }
}
[data-slot="color-field-skeleton"] [data-line] {
animation: color-field-skeleton-pulse 2s ease-in-out infinite;
}
`
/**
* A skeleton of pulsing bars on a Rothko-style ground, shown while content
* loads. It waits in gray and glazes the palette's color in over 1.2s when
* `colored` turns true.
*
* Exposes `role="status"` with the label "Loading content"; pass
* `aria-label` to change it. The pulse changes opacity only.
*/
function ColorFieldSkeleton({
palette = {
ground: "oklch(0.315 0.064 270.3)",
upper:
"radial-gradient(ellipse 135% 105% at 50% 35%, oklch(0.875 0.097 100.4) 0%, oklch(0.850 0.101 100.7 / 0.7) 52%, transparent 100%)",
lower:
"radial-gradient(ellipse 130% 95% at 50% 80%, oklch(0.365 0.073 269.3) 0%, oklch(0.365 0.073 269.3 / 0.8) 56%, transparent 100%)",
glow: "oklch(0.936 0.055 102.9)",
form: "strong",
},
lines = 3,
animated = true,
colored = false,
className,
style,
...props
}: React.ComponentProps<"div"> & {
/**
* The ground and alternating `upper` and `lower` bar colors. Accepts CSS
* colors, including theme variables. Existing Orbital gradients use their
* first `oklch()` color, or the ground if none is present. Other fields
* are ignored.
*/
palette?: RothkoOrbPalette
/** Number of bars. The last one is 60% wide. */
lines?: number
/** Pulses the bars' opacity while true. */
animated?: boolean
/** False desaturates the palette, preserving lightness and alpha; true restores color. */
colored?: boolean
}) {
const fields = rothkoFieldsOf(palette)
const lineColors = [fields.upper, fields.lower]
const develop = { transition: "background 1.2s ease" }
return (
<div
role="status"
aria-label="Loading content"
className={cn(
"relative flex flex-col gap-3 overflow-hidden rounded-md p-5",
className
)}
style={{
background: colored ? fields.ground : grisaille(fields.ground),
...develop,
...style,
}}
// After the spread: the pulse CSS above selects on this slot.
data-slot="color-field-skeleton"
{...props}
>
{animated ? <style>{PULSE_CSS}</style> : null}
{Array.from({ length: lines }).map((_, i) => {
const color = lineColors[i % lineColors.length]
return (
<div
key={i}
data-line={animated ? "" : undefined}
className={cn(
"h-3 rounded opacity-60",
i === lines - 1 ? "w-3/5" : "w-full"
)}
style={{
background: colored ? color : grisaille(color),
animationDelay: `${i * 0.3}s`,
...develop,
}}
/>
)
})}
<div
aria-hidden
className="absolute inset-0 opacity-25 mix-blend-overlay"
style={{ backgroundImage: PAPER_GRAIN }}
/>
</div>
)
}
type OrbitalPaletteData = {
ground: string
upper: string
lower: string
glow?: string
form?: "off" | "medium" | "strong"
period?: "nocturne"
}
type RothkoOrbPalette = OrbitalPaletteData
function coreOf(field: string, fallback: string) {
if (
!/^(?:repeating-)?(?:linear|radial|conic)-gradient\(/i.test(field.trim())
) {
return field
}
// Existing Orbital palettes supply gradients. Keep their first OKLCH stop,
// including nested var() and calc() expressions, as the solid bar color.
const start = field.search(/oklch\(/i)
if (start === -1) return fallback
let depth = 0
for (let i = start; i < field.length; i++) {
if (field[i] === "(") depth++
if (field[i] === ")") {
depth--
if (depth === 0) return field.slice(start, i + 1)
}
}
return fallback
}
function rothkoFieldsOf(palette: RothkoOrbPalette) {
const p = palette
return {
ground: p.ground,
upper: coreOf(p.upper, p.ground),
lower: coreOf(p.lower, p.ground),
}
}
function grisaille(color: string) {
return `oklch(from ${color} l 0 h)`
}
export { ColorFieldSkeleton }
// The grain tiles the kit paints with, as data URIs, each held once. Every
// recipe is a tiny SVG turbulence filter; the component using it chooses
// the tile size, opacity and blend. `*_URI` is the bare image for callers
// that wrap it themselves; the unsuffixed name is ready for `background-image`.
/** Stylized paper: coarse turbulence pushed to high contrast, the grain under most of the painted scenes and the Grain `paper` variant. */
export const PAPER_GRAIN_URI =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='250' height='250' viewBox='0 0 100 100'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='turbulence' baseFrequency='1.4' numOctaves='1' seed='2' stitchTiles='stitch' result='n'/%3E%3CfeComponentTransfer result='g'%3E%3CfeFuncR type='linear' slope='4' intercept='1'/%3E%3CfeFuncG type='linear' slope='4' intercept='1'/%3E%3CfeFuncB type='linear' slope='4' intercept='1'/%3E%3C/feComponentTransfer%3E%3CfeColorMatrix type='saturate' values='0' in='g'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"
export const PAPER_GRAIN = `url("${PAPER_GRAIN_URI}")`
/** Sandpaper: finer, two-octave turbulence, the Grain `sandpaper` variant and the pours and pools. */
export const SANDPAPER_GRAIN_URI =
"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200' viewBox='0 0 100 100'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='turbulence' baseFrequency='2.2' numOctaves='2' seed='5' stitchTiles='stitch' result='n'/%3E%3CfeComponentTransfer result='g'%3E%3CfeFuncR type='linear' slope='2.5' intercept='0.8'/%3E%3CfeFuncG type='linear' slope='2.5' intercept='0.8'/%3E%3CfeFuncB type='linear' slope='2.5' intercept='0.8'/%3E%3C/feComponentTransfer%3E%3CfeColorMatrix type='saturate' values='0' in='g'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"
export const SANDPAPER_GRAIN = `url("${SANDPAPER_GRAIN_URI}")`
/** Square: three-octave turbulence at high contrast, the Grain `square` variant. */
export const SQUARE_GRAIN_URI =
"data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%22200%22%20height=%22200%22%20viewBox=%220%200%20100%20100%22%3E%3Cfilter%20id=%22n%22%3E%3CfeTurbulence%20type=%22turbulence%22%20baseFrequency=%222.1%22%20numOctaves=%223%22%20seed=%2211%22%20stitchTiles=%22stitch%22%20result=%22n%22/%3E%3CfeComponentTransfer%20result=%22g%22%3E%3CfeFuncR%20type=%22linear%22%20slope=%225.5%22%20intercept=%221.4%22/%3E%3CfeFuncG%20type=%22linear%22%20slope=%225.5%22%20intercept=%221.4%22/%3E%3CfeFuncB%20type=%22linear%22%20slope=%225.5%22%20intercept=%221.4%22/%3E%3C/feComponentTransfer%3E%3CfeColorMatrix%20type=%22saturate%22%20values=%220%22%20in=%22g%22/%3E%3C/filter%3E%3Crect%20width=%22100%%22%20height=%22100%%22%20filter=%22url(%23n)%22/%3E%3C/svg%3E"
export const SQUARE_GRAIN = `url("${SQUARE_GRAIN_URI}")`
/** Plain fractal noise on a 200px tile, the Grain `single` variant. */
export const SINGLE_NOISE_URI =
"data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"
export const SINGLE_NOISE = `url("${SINGLE_NOISE_URI}")`
/** Fine fractal noise on a 512px tile, SoftGrain and the Limb scene. */
export const SOFT_NOISE_URI =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='5' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"
export const SOFT_NOISE = `url("${SOFT_NOISE_URI}")`
/** Fine fractal noise the field scenes lay over their art: Fracture Field, Gestural Zip, Solar Flare and Underwater Shaft. */
export const FIELD_NOISE_URI =
"data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"
export const FIELD_NOISE = `url("${FIELD_NOISE_URI}")`
/** Fine fractal noise on a 256px tile, the mist over the radial charts. */
export const CHART_NOISE_URI =
"data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"
export const CHART_NOISE = `url("${CHART_NOISE_URI}")`
/** A one-octave dither on a 128px tile, the glass of the neon signs. */
export const DITHER_NOISE_URI =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E"
export const DITHER_NOISE = `url("${DITHER_NOISE_URI}")`
/** A thin film: paper turbulence with its alpha cut to 0.6, the grain GlowSection lays over its light. */
export const GLOW_FILM_URI =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='250' height='250'%3E%3Cfilter id='sf'%3E%3CfeTurbulence type='turbulence' baseFrequency='1.4' numOctaves='1' seed='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.6'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23sf)'/%3E%3C/svg%3E"
export const GLOW_FILM = `url("${GLOW_FILM_URI}")`
/** Plain fractal noise on a 400px tile, the canvas finish of ColorField and FeltOrb. */
export const CANVAS_NOISE_URI =
"data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"
export const CANVAS_NOISE = `url("${CANVAS_NOISE_URI}")`
"use client"
import { useEffect, useState } from "react"
import { ColorFieldSkeleton } from "@/components/color-field-skeleton/color-field-skeleton"
const PALETTES = {
poppy: {
ground: "oklch(0.436 0.110 259.5)",
upper:
"radial-gradient(ellipse 130% 100% at 50% 35%, oklch(0.627 0.214 26.0) 0%, oklch(0.627 0.214 26.0 / 0.7) 52%, transparent 100%)",
lower:
"radial-gradient(ellipse 130% 95% at 50% 80%, oklch(0.477 0.080 189.0) 0%, oklch(0.452 0.075 207.5 / 0.75) 56%, transparent 100%)",
glow: "oklch(0.668 0.220 29.3)",
form: "strong",
},
} as const
export default function ColorFieldSkeletonDemo() {
const [colored, setColored] = useState(false)
useEffect(() => {
const tick = setInterval(() => setColored((c) => !c), 2800)
return () => clearInterval(tick)
}, [])
return (
<div className="w-72">
<ColorFieldSkeleton
palette={PALETTES["poppy"]}
lines={4}
colored={colored}
/>
</div>
)
}
Usage
"use client"
import { useEffect, useState } from "react"
import { ColorFieldSkeleton } from "@/components/color-field-skeleton/color-field-skeleton"
const PALETTES = {
poppy: {
ground: "oklch(0.436 0.110 259.5)",
upper:
"radial-gradient(ellipse 130% 100% at 50% 35%, oklch(0.627 0.214 26.0) 0%, oklch(0.627 0.214 26.0 / 0.7) 52%, transparent 100%)",
lower:
"radial-gradient(ellipse 130% 95% at 50% 80%, oklch(0.477 0.080 189.0) 0%, oklch(0.452 0.075 207.5 / 0.75) 56%, transparent 100%)",
glow: "oklch(0.668 0.220 29.3)",
form: "strong",
},
} as const
export default function ColorFieldSkeletonDemo() {
const [colored, setColored] = useState(false)
useEffect(() => {
const tick = setInterval(() => setColored((c) => !c), 2800)
return () => clearInterval(tick)
}, [])
return (
<div className="w-72">
<ColorFieldSkeleton
palette={PALETTES["poppy"]}
lines={4}
colored={colored}
/>
</div>
)
}API reference
A skeleton of pulsing bars on a Rothko-style ground, shown while content loads. It waits in gray and glazes the palette's color in over 1.2s when colored turns true.
Exposes role="status" with the label "Loading content"; pass aria-label to change it. The pulse changes opacity only.
Prop
Type
It also accepts the HTML attributes of the element it renders.
Dependencies
npm: cn. The CLI installs them for you.
No other registry pieces.