docs / sula / sula-field
SulaField
an ambient veil of slow liquid glass: dark masses heave behind, lit glass drifts and kisses in front, and your content stays crisp and interactive above it.
intensity · assertsrsc · client
provenance
authored in usvalayer
sulaintensity
asserts · one per region. it is the focal pointcomposition
behind a landing hero, a splash, a section break. one per viewno children makes it a bare background layer you position yourselfnever under a second sula surface, and never behind a work surfacenever CSS-scale it. the pool spills out of the transformed boxa11y
the canvas layer isaria-hidden and pointer-transparent · content renders above it exactly as it would with no fieldjest-axedependencies
motion · ogl · sula-core from the same packagelive demo · try it out
mist, made of glass
slow glass gathers at the edges and leaves the words clear
customize
speeddrift-rate multiplier
interactivethe veil leans toward the pointer
seedsame value wanders the same way
fluidoff mounts no canvas, one static frame
usage
import { SulaField } from "usva/sula/sula-field";
<SulaField
>
<Hero />
</SulaField>props
| prop | type | default | notes |
|---|---|---|---|
| children | ReactNode | — | rendered in normal flow above the field. omit it for a bare layer you position yourself. |
| speed | number | 1 | drift-rate multiplier. higher drifts faster. |
| drive | SulaFieldDrive | ambientDrift | the choreography: a pure function of time and bounds. clamped to 12 bodies per plane and 8 necks, the surplus is not drawn. |
| interactive | boolean | false | the field leans toward the eased cursor. off so the background does not compete. |
| seed | number | 0 | the same value wanders the same way, across renders and SSR. |
| fluid | boolean | true | false mounts no canvas. reduced motion paints one static frame instead. |
| stillTime | number | 0 | which instant of the drive the reduced-motion still is taken from, in seconds. |
| accentColor | string | accent token | rim light and glow on the blobs. |
| backdrop | string | bg token | the colour the glass tints against. |
| tint | string | surface-2 token | the glass itself. |
| shine | number | per theme | 0 is flat matte glass, 1 is the full neon rim. |
get it
npx shadcn add https://usva.build/r/sula-field.jsonsource · components/ui/field-geometry.tsexactly what this command copies
import { type Blob, bridgeNecks, type Neck } from "./geometry";
import { clamp01, smoother } from "./curves";
export interface DriftOpts {
width: number;
height: number;
/** Feeds every path phase, so a seed reproduces the same wander. */
seed: number;
}
export interface FieldGeometry {
/** Two huge, dark, shoulder-off-canvas masses: the atmosphere, no rim. */
back: Blob[];
/** Lit actors and glints that drift, kiss and part in front. */
front: Blob[];
/** Scheduled-kiss bridges among the front actors. */
necks: Neck[];
}
const TAU = Math.PI * 2;
/** The full loop closes at 96 s: every band's cycle count is an integer, so the
* whole field returns to its start seamlessly and deterministically. */
export const LOOP_T = 96;
/** Fraction of the short side used as the front merge radius. Higher than the
* old single-pass k so a kiss reads as a fat waist, not a thread. */
export const FRONT_K_FRACTION = 0.11;
/** The back pass merges soupy and huge, so its two anchors read as one cloud. */
export const BACK_K_FRACTION = 0.2;
/** Eased approach of the pointer lean, per frame. */
export const POINTER_EASE = 0.08;
/** The heavy wake that drags a blob toward the cursor is slower still: a liquid
* has mass, so it lags. */
export const WAKE_EASE = 0.03;
/**
* A deterministic hash in [0, 1) from an integer stream and the seed. Only has to
* look unpatterned and stay identical across renders so the drift is stable for
* SSR and tests.
*/
function hash(n: number, seed: number): number {
const x = Math.sin((n + 1) * 12.9898 + seed * 78.233) * 43758.5453;
return x - Math.floor(x);
}
const clamp = (v: number, lo: number, hi: number): number =>
Math.min(hi, Math.max(lo, v));
/** Three incommensurate sine bands with descending weight. Integer cycle counts
* over LOOP_T keep it seamless; the coprime-ish triples keep the period from
* ever visibly repeating inside two minutes. */
const BAND_WEIGHTS = [0.6, 0.3, 0.1];
function drift(
time: number,
amp: number,
cycles: readonly [number, number, number],
phase: number,
): number {
let s = 0;
for (let j = 0; j < 3; j++) {
const f = ((cycles[j] as number) * TAU) / LOOP_T;
s += (BAND_WEIGHTS[j] as number) * Math.sin(time * f + phase + j * 2.399);
}
return amp * s;
}
interface Spec {
baseX: number;
baseY: number;
r: number;
amp: number;
cyclesX: readonly [number, number, number];
cyclesY: readonly [number, number, number];
breath: number;
offCanvas: boolean;
}
function place(spec: Spec, time: number, opts: DriftOpts, id: number): Blob {
const { width, height, seed } = opts;
const px = hash(id * 16 + 6, seed) * TAU;
const py = hash(id * 16 + 7, seed) * TAU;
const breathe =
1 +
spec.breath *
Math.sin((time * TAU) / LOOP_T + hash(id * 16 + 8, seed) * TAU);
const r = spec.r * breathe;
const dx = drift(time, spec.amp, spec.cyclesX, px);
const dy = drift(time, spec.amp, spec.cyclesY, py);
const slackX = spec.offCanvas ? 0.35 * r : r;
const slackY = spec.offCanvas ? 0.35 * r : r;
const cx = clamp(spec.baseX * width + dx, -slackX, width + slackX);
const cy = clamp(spec.baseY * height + dy, -slackY, height + slackY);
return { cx, cy, hw: r, hh: r, r };
}
/** One scheduled encounter: which two front actors meet, when in the loop, and
* how wide the window is (as loop fractions). */
const KISSES: ReadonlyArray<{
i: number;
j: number;
at: number;
half: number;
}> = [
{ i: 0, j: 1, at: 0.22, half: 0.06 },
{ i: 1, j: 2, at: 0.61, half: 0.06 },
{ i: 0, j: 2, at: 0.87, half: 0.05 },
];
export function fieldFrame(time: number, opts: DriftOpts): FieldGeometry {
const { width, height } = opts;
const short = Math.min(width, height);
const backSpecs: Spec[] = [
{
baseX: 0.16,
baseY: 0.84,
r: short * 0.45,
amp: short * 0.04,
cyclesX: [1, 3, 7],
cyclesY: [2, 5, 11],
breath: 0.06,
offCanvas: true,
},
{
baseX: 0.86,
baseY: 0.2,
r: short * 0.34,
amp: short * 0.045,
cyclesX: [3, 7, 13],
cyclesY: [1, 5, 9],
breath: 0.06,
offCanvas: true,
},
];
const midSpecs: Spec[] = [
{ baseX: 0.3, baseY: 0.68, r: short * 0.16 },
{ baseX: 0.52, baseY: 0.5, r: short * 0.13 },
{ baseX: 0.68, baseY: 0.36, r: short * 0.11 },
].map((s) => ({
...s,
amp: short * 0.12,
cyclesX: [2, 5, 11],
cyclesY: [3, 8, 13],
breath: 0.05,
offCanvas: false,
}));
const glintSpecs: Spec[] = [
{ baseX: 0.72, baseY: 0.72, r: short * 0.05 },
{ baseX: 0.34, baseY: 0.24, r: short * 0.035 },
].map((s) => ({
...s,
amp: short * 0.18,
cyclesX: [3, 8, 13],
cyclesY: [5, 13, 21],
breath: 0.04,
offCanvas: false,
}));
const back = backSpecs.map((s, i) => place(s, time, opts, i));
const mid = midSpecs.map((s, i) => place(s, time, opts, 10 + i));
const glints = glintSpecs.map((s, i) => place(s, time, opts, 20 + i));
const k = short * FRONT_K_FRACTION;
const loopPhase = (((time % LOOP_T) + LOOP_T) % LOOP_T) / LOOP_T;
const necks: Neck[] = [];
for (const kiss of KISSES) {
const w = 1 - clamp01(Math.abs(loopPhase - kiss.at) / kiss.half);
if (w <= 0) continue;
const ramp = smoother(w);
const a = mid[kiss.i] as Blob;
const b = mid[kiss.j] as Blob;
const dx = b.cx - a.cx;
const dy = b.cy - a.cy;
const dist = Math.hypot(dx, dy) || 1;
const ux = dx / dist;
const uy = dy / dist;
const midX = (a.cx + b.cx) / 2;
const midY = (a.cy + b.cy) / 2;
const pull = ramp * 0.92;
a.cx += (midX - ux * (a.r + 0.15 * k) - a.cx) * pull;
a.cy += (midY - uy * (a.r + 0.15 * k) - a.cy) * pull;
b.cx += (midX + ux * (b.r + 0.15 * k) - b.cx) * pull;
b.cy += (midY + uy * (b.r + 0.15 * k) - b.cy) * pull;
const stretch = 1 + 0.1 * ramp;
a.hw *= stretch;
b.hw *= stretch;
necks.push(...bridgeNecks([a, b], k, ramp));
}
return { back, front: [...mid, ...glints], necks };
}
/** Finds the surface under pressure, rather than making the whole veil follow
* the pointer through its first (and largest) blob. */
export function nearestBlob(
blobs: Blob[],
point: { x: number; y: number },
): Blob | undefined {
let nearest: Blob | undefined;
let nearestDistance = Number.POSITIVE_INFINITY;
for (const blob of blobs) {
const distance =
Math.hypot(blob.cx - point.x, blob.cy - point.y) -
Math.max(blob.hw, blob.hh);
if (distance < nearestDistance) {
nearest = blob;
nearestDistance = distance;
}
}
return nearest;
}
source · components/ui/drive.tsexactly what this command copies
import type { Blob, Neck } from "./geometry";
import {
BACK_K_FRACTION,
FRONT_K_FRACTION,
fieldFrame,
} from "./field-geometry";
/**
* How many bodies each depth plane may hold, and how many necks the whole frame
* may hold. The shader's uniform arrays are fixed, so these are hard ceilings:
* a drive that hands back more is clamped to the first N, and told so once in
* development. Silently dropping the tail is how a tear ends up with no neck.
*/
export const MAX_FIELD_BLOBS = 12;
export const MAX_FIELD_NECKS = 8;
/** A rounded box of fluid, in CSS pixels from the field's top-left corner. */
export interface SulaBlob {
cx: number;
cy: number;
/** Corner radius. A body with hw = hh = r is a circle. */
r: number;
/** Half-width. Defaults to `r`. */
hw?: number;
/** Half-height. Defaults to `r`. */
hh?: number;
}
/**
* A capsule joining two points: the tether of a tear, or the waist of a merge.
* `strength` fades the bridge back into the surface without thinning it, which
* is the only way a neck melts instead of snapping. Defaults to a solid bridge.
*/
export interface SulaNeck {
ax: number;
ay: number;
bx: number;
by: number;
r: number;
strength?: number;
}
/** The field's box, in CSS pixels, plus the seed the consumer was handed. */
export interface SulaFieldBounds {
width: number;
height: number;
seed: number;
}
/**
* What the fluid is doing at one instant. The consumer says what the material is
* doing; the field decides how to paint it.
*/
export interface SulaDriveFrame {
/** Matte bodies behind everything, drawn without a rim. Depth, not actors. */
back?: SulaBlob[];
/** The lit bodies. */
front?: SulaBlob[];
/** Bridges among the front bodies. */
necks?: SulaNeck[];
/** Merge radius of the front plane, in px. Defaults to 11% of the short side. */
mergeRadius?: number;
/** Merge radius of the back plane, in px. Defaults to 20% of the short side. */
backMergeRadius?: number;
}
/**
* A pure function of time. Given the seconds elapsed (already scaled by `speed`)
* and the field's bounds, it returns the frame. Deterministic by contract: the
* same time and bounds must give the same frame, so a drive can be unit-tested
* and a still frame under reduced motion is just the frame at t = 0.
*/
export type SulaFieldDrive = (
time: number,
bounds: SulaFieldBounds,
) => SulaDriveFrame;
export interface ResolvedDriveFrame {
back: Blob[];
front: Blob[];
necks: Neck[];
kFront: number;
kBack: number;
/** True when the drive handed back more than a plane can hold. */
clamped: boolean;
}
const toBlob = (b: SulaBlob): Blob => ({
cx: b.cx,
cy: b.cy,
hw: b.hw ?? b.r,
hh: b.hh ?? b.r,
r: b.r,
});
/**
* Turns whatever the drive said into exactly what the renderer can take: plane
* budgets enforced, half-extents filled in, merge radii defaulted off the short
* side so a drive never has to know the field's pixel size to look right.
*/
export function resolveDriveFrame(
frame: SulaDriveFrame,
bounds: SulaFieldBounds,
): ResolvedDriveFrame {
const short = Math.min(bounds.width, bounds.height);
const back = frame.back ?? [];
const front = frame.front ?? [];
const necks = frame.necks ?? [];
return {
back: back.slice(0, MAX_FIELD_BLOBS).map(toBlob),
front: front.slice(0, MAX_FIELD_BLOBS).map(toBlob),
necks: necks.slice(0, MAX_FIELD_NECKS).map((n) => ({ ...n })),
kFront: frame.mergeRadius ?? short * FRONT_K_FRACTION,
kBack: frame.backMergeRadius ?? short * BACK_K_FRACTION,
clamped:
back.length > MAX_FIELD_BLOBS ||
front.length > MAX_FIELD_BLOBS ||
necks.length > MAX_FIELD_NECKS,
};
}
/** The built-in choreography: the ambient drift the field has always had. */
export const ambientDrift: SulaFieldDrive = (time, bounds) => {
const { back, front, necks } = fieldFrame(time, bounds);
return { back, front, necks };
};
source · components/ui/sula-field.tsxexactly what this command copies
"use client";
import { useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
createField,
type FieldColors,
liftTint,
resolveColor,
shineForBackdrop,
} from "./field";
import { packHover, packUniforms } from "./geometry";
import { createPauseGate } from "./pause";
import { useContextRecovery } from "./recovery";
import {
ambientDrift,
MAX_FIELD_BLOBS,
MAX_FIELD_NECKS,
resolveDriveFrame,
type SulaFieldDrive,
} from "./drive";
import { nearestBlob, POINTER_EASE, WAKE_EASE } from "./field-geometry";
// Bundlers replace this expression statically, but a Vite app types itself with
// `types: ["vite/client"]` and has no `process`, so copied source fails tsc
// without a local declaration. Module scope, so it shadows nothing in Next.
declare const process: { env: { NODE_ENV?: string } };
export interface SulaFieldProps extends React.HTMLAttributes<HTMLDivElement> {
/** Drift-rate multiplier; higher drifts faster. Defaults to 1. */
speed?: number;
/**
* The choreography: a pure function of time and the field's bounds, returning
* the bodies and necks for that instant. Defaults to the ambient drift. Each
* depth plane holds up to MAX_FIELD_BLOBS bodies and the frame up to
* MAX_FIELD_NECKS necks; anything past that is clamped away, so keep inside it.
*/
drive?: SulaFieldDrive;
/** When on, blobs lean toward the eased cursor. Defaults to false. */
interactive?: boolean;
/** Reproduces the same wander for a given value. Defaults to 0. */
seed?: number;
/** false mounts no canvas; reduced-motion paints one static frame. */
fluid?: boolean;
/**
* Which instant of the drive the reduced-motion still frame is taken from, in
* seconds. Defaults to 0, which is right for a drive that is already composed
* at rest, and wrong for one that has to run before there is anything to see:
* a cycle that lifts bodies out of a pool is an empty pool at t=0. Pick the
* moment that reads as the whole idea, held.
*/
stillTime?: number;
accentColor?: string;
backdrop?: string;
tint?: string;
shine?: number;
children?: React.ReactNode;
}
const MAX_DPR = 2;
/** Peak surface undulation, per depth pass. The back cloud heaves slowly; the
* front actors shimmer tighter. */
const BACK_WOBBLE = 2.5;
const FRONT_WOBBLE = 1.2;
/** Peak edge displacement of the pointer lean, in px. */
const HOVER_WOBBLE = 1.4;
/** Peak position lean toward the cursor, as a fraction of the short side: the
* heavy wake that trails the pointer. */
const WAKE_REACH = 0.06;
/** Gaussian falloff radius of the wake, as a fraction of the short side. Every
* actor within it leans, weighted by proximity, so the pull glides across the
* field instead of snapping from one nearest blob to the next. */
const WAKE_SPREAD = 0.45;
function readColors(
node: HTMLElement,
overrides: {
backdrop?: string;
tint?: string;
accent?: string;
shine?: number;
},
): FieldColors {
const styles = getComputedStyle(node);
const token = (name: string) => styles.getPropertyValue(name).trim();
const backdrop = resolveColor(overrides.backdrop ?? token("--usva-bg"));
const tintToken =
overrides.tint ?? token("--usva-surface-2") ?? token("--usva-surface");
return {
backdrop,
tint: resolveColor(tintToken || token("--usva-surface")),
accent: resolveColor(overrides.accent ?? token("--usva-accent")),
shine: overrides.shine ?? shineForBackdrop(backdrop),
};
}
export const SulaField = React.forwardRef<HTMLDivElement, SulaFieldProps>(
(
{
speed = 1,
drive,
interactive = false,
seed = 0,
fluid = true,
stillTime = 0,
accentColor,
backdrop,
tint,
shine,
className,
children,
...props
},
forwardedRef,
) => {
const reduced = useReducedMotion();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
/* These tune what the loop draws, not the GL field, so they ride refs rather
* than effect deps: a live prop change must never tear the context down and
* rebuild it on the same canvas, which races a scheduled frame. */
const speedRef = React.useRef(speed);
speedRef.current = speed;
const seedRef = React.useRef(seed);
seedRef.current = seed;
const interactiveRef = React.useRef(interactive);
interactiveRef.current = interactive;
const driveRef = React.useRef<SulaFieldDrive>(drive ?? ambientDrift);
driveRef.current = drive ?? ambientDrift;
const { failed, generation, onContextLost, onContextReady } =
useContextRecovery(canvasRef);
const overrides = React.useMemo(
() => ({ backdrop, tint, accent: accentColor, shine }),
[backdrop, tint, accentColor, shine],
);
const overridesRef = React.useRef(overrides);
overridesRef.current = overrides;
/* This surface swaps two colour sets per pass, so a retune recomputes both
* rather than pushing one through setColors, which the next pass would
* overwrite anyway. */
const refreshRef = React.useRef<(() => void) | null>(null);
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
const animated = fluid && !reduced && !failed && mounted;
const still = fluid && reduced && !failed && mounted;
const fieldOn = animated || still;
// biome-ignore lint/correctness/useExhaustiveDependencies: `generation` is not read here, it is what rebuilds the field on a restored context
React.useEffect(() => {
if (!fieldOn) return;
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
let base = readColors(container, overridesRef.current);
let backColors: FieldColors = { ...base, shine: 0 };
let frontColors: FieldColors = {
...base,
tint: liftTint(base.tint, base.accent),
};
const refreshColors = () => {
base = readColors(container, overridesRef.current);
backColors = { ...base, shine: 0 };
frontColors = { ...base, tint: liftTint(base.tint, base.accent) };
};
refreshRef.current = refreshColors;
const field = createField({
canvas,
colors: frontColors,
onContextLost,
});
if (!field) {
onContextLost();
return;
}
onContextReady();
let width = 0;
let height = 0;
let dpr = 1;
const measure = () => {
const box = container.getBoundingClientRect();
const w = Math.ceil(box.width);
const h = Math.ceil(box.height);
if (w <= 0 || h <= 0) return false;
const nextDpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
if (w !== width || h !== height || nextDpr !== dpr) {
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
field.resize(w, h, nextDpr);
}
width = w;
height = h;
dpr = nextDpr;
return true;
};
let hoverAmt = 0;
let hoverTarget = 0;
let wakeAmt = 0;
const pointer = { x: 0, y: 0 };
/* Two passes into one context: a dark, soupy back cloud clears the frame,
* then the lit actors composite over it (clear:false). Colors are set per
* pass, so the back stays matte (shine 0) and the front carries the rim. */
let warned = false;
const draw = (elapsed: number) => {
const bounds = { width, height, seed: seedRef.current };
const resolved = resolveDriveFrame(
driveRef.current(elapsed * speedRef.current, bounds),
bounds,
);
const { back, front, necks, kFront, kBack } = resolved;
if (resolved.clamped && !warned) {
warned = true;
if (process.env.NODE_ENV !== "production") {
console.warn(
`SulaField: the drive returned more than ${MAX_FIELD_BLOBS} bodies in a plane or ${MAX_FIELD_NECKS} necks. The surplus is not drawn.`,
);
}
}
const short = Math.min(width, height);
const focus = nearestBlob(front, pointer);
if (interactiveRef.current && wakeAmt > 0.001) {
const reach = wakeAmt * WAKE_REACH * short;
const spread = short * WAKE_SPREAD;
for (const b of front) {
const dx = pointer.x - b.cx;
const dy = pointer.y - b.cy;
const dist = Math.hypot(dx, dy) || 1;
const fall = Math.exp(-((dist / spread) ** 2));
b.cx += (dx / dist) * reach * fall;
b.cy += (dy / dist) * reach * fall;
}
}
field.setColors(backColors);
field.draw({
packed: packUniforms(
{ blobs: back, necks: [], k: kBack },
dpr,
height,
),
k: kBack * dpr,
time: elapsed,
wobble: BACK_WOBBLE,
alpha: 1,
hover: null,
clear: true,
});
field.setColors(frontColors);
field.draw({
packed: packUniforms({ blobs: front, necks, k: kFront }, dpr, height),
k: kFront * dpr,
time: elapsed,
wobble: FRONT_WOBBLE,
alpha: 1,
hover:
interactiveRef.current && focus && hoverAmt > 0.01
? packHover(focus, hoverAmt * HOVER_WOBBLE, dpr, height, pointer)
: null,
clear: false,
});
};
const start = performance.now();
if (!measure()) return;
if (still) {
draw(stillTime);
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(() => {
if (measure()) draw(stillTime);
});
observer?.observe(container);
return () => {
observer?.disconnect();
field.dispose();
refreshRef.current = null;
canvas.style.width = "";
canvas.style.height = "";
};
}
let raf = 0;
let elapsed = 0;
let last = start;
let killed = false;
const tick = () => {
if (killed) return;
const now = performance.now();
elapsed += (now - last) / 1000;
last = now;
hoverAmt += (hoverTarget - hoverAmt) * POINTER_EASE;
wakeAmt += (hoverTarget - wakeAmt) * WAKE_EASE;
draw(elapsed);
raf = requestAnimationFrame(tick);
};
const stop = () => cancelAnimationFrame(raf);
const run = () => {
last = performance.now();
stop();
raf = requestAnimationFrame(tick);
};
const onMove = (event: PointerEvent) => {
if (!interactiveRef.current) return;
const box = container.getBoundingClientRect();
pointer.x = event.clientX - box.left;
pointer.y = event.clientY - box.top;
hoverTarget = 1;
};
const onLeave = () => {
hoverTarget = 0;
};
container.addEventListener("pointermove", onMove);
container.addEventListener("pointerleave", onLeave);
const gate = createPauseGate({
target: container,
onPause: stop,
onResume: run,
});
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(() => measure());
observer?.observe(container);
const themeObserver =
typeof MutationObserver === "undefined"
? null
: new MutationObserver(() => refreshColors());
themeObserver?.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "class"],
});
run();
return () => {
killed = true;
container.removeEventListener("pointermove", onMove);
container.removeEventListener("pointerleave", onLeave);
gate.dispose();
observer?.disconnect();
themeObserver?.disconnect();
stop();
field.dispose();
canvas.style.width = "";
canvas.style.height = "";
};
}, [fieldOn, still, stillTime, generation]);
// biome-ignore lint/correctness/useExhaustiveDependencies: `overrides` is not read here, the effect closure reads it live
React.useEffect(() => {
refreshRef.current?.();
}, [overrides]);
return (
<div
ref={(node) => {
containerRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}}
data-fluid={fieldOn ? "on" : "off"}
className={cn("relative isolate overflow-hidden", className)}
{...props}
>
{fieldOn ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10"
style={{
backgroundImage:
"radial-gradient(120% 90% at 12% 88%, color-mix(in oklab, var(--usva-accent) 7%, transparent), transparent 55%)",
}}
>
<canvas ref={canvasRef} className="block h-full w-full" />
</div>
) : null}
{children}
</div>
);
},
);
SulaField.displayName = "SulaField";