Draft
A slow-drifting gradient mesh background. Pure CSS, zero dependencies, SSR-safe, honors prefers-reduced-motion.
cssfree
import * as React from "react";
import { cn } from "@/lib/utils";
export interface DraftProps extends React.ComponentPropsWithoutRef<"div"> {
/** Blob colors, any CSS color. Defaults to a warm forge palette. */
colors?: [string, string, string, string];
/** Animation speed multiplier. 1 = default, 2 = twice as fast. */
speed?: number;
/** Freeze the animation at its current frame. */
paused?: boolean;
/** Blur radius in px applied to the blobs. */
blur?: number;
}
/**
* Draft — a slow-drifting gradient mesh background. Pure CSS animation:
* zero dependencies, SSR-safe, and honors prefers-reduced-motion.
* Position it inside any `relative` container; it fills and sits behind content.
*/
export function Draft({
colors = ["#ff6b35", "#b23a48", "#2e1f27", "#7b4b94"],
speed = 1,
paused = false,
blur = 90,
className,
style,
...props
}: DraftProps) {
const dur = (base: number) => `${base / Math.max(speed, 0.01)}s`;
return (
<div
aria-hidden
data-crucible="draft"
className={cn("absolute inset-0 -z-10 overflow-hidden", className)}
style={
{
"--draft-c1": colors[0],
"--draft-c2": colors[1],
"--draft-c3": colors[2],
"--draft-c4": colors[3],
"--draft-blur": `${blur}px`,
"--draft-play": paused ? "paused" : "running",
...style,
} as React.CSSProperties
}
{...props}
>
<style>{`
@keyframes crucible-draft-a {
0% { transform: translate(-12%, -8%) scale(1); }
33% { transform: translate(14%, 6%) scale(1.25); }
66% { transform: translate(-4%, 16%) scale(0.9); }
100% { transform: translate(-12%, -8%) scale(1); }
}
@keyframes crucible-draft-b {
0% { transform: translate(10%, 12%) scale(1.1); }
50% { transform: translate(-16%, -6%) scale(0.85); }
100% { transform: translate(10%, 12%) scale(1.1); }
}
@keyframes crucible-draft-c {
0% { transform: translate(0%, -14%) scale(0.95) rotate(0deg); }
50% { transform: translate(8%, 10%) scale(1.2) rotate(12deg); }
100% { transform: translate(0%, -14%) scale(0.95) rotate(0deg); }
}
[data-crucible="draft"] .draft-blob {
animation-play-state: var(--draft-play);
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
[data-crucible="draft"] .draft-blob { animation: none; }
}
`}</style>
<div
className="draft-blob absolute -top-1/4 -left-1/4 h-[80%] w-[80%] rounded-full opacity-70"
style={{
background: "radial-gradient(circle at center, var(--draft-c1), transparent 65%)",
filter: "blur(var(--draft-blur))",
animation: `crucible-draft-a ${dur(26)} ease-in-out infinite`,
}}
/>
<div
className="draft-blob absolute -right-1/4 -bottom-1/4 h-[85%] w-[85%] rounded-full opacity-60"
style={{
background: "radial-gradient(circle at center, var(--draft-c2), transparent 65%)",
filter: "blur(var(--draft-blur))",
animation: `crucible-draft-b ${dur(32)} ease-in-out infinite`,
}}
/>
<div
className="draft-blob absolute top-1/4 left-1/3 h-[70%] w-[70%] rounded-full opacity-50"
style={{
background: "radial-gradient(circle at center, var(--draft-c4), transparent 60%)",
filter: "blur(var(--draft-blur))",
animation: `crucible-draft-c ${dur(38)} ease-in-out infinite`,
}}
/>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(180deg, transparent 0%, color-mix(in oklab, var(--draft-c3) 55%, transparent) 100%)",
}}
/>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/draftHonors prefers-reduced-motion with a designed static fallback, and passes className through.