docs / sula / sula-frame
SulaFrame
a liquid border that hugs a rounded rectangle, calm at rest and leaning toward the cursor. wrap a card for a glowing brand edge, or set fixed to frame the whole viewport.
intensity · assertsrsc · client
provenance
shaped from personal-websitelayer
sulaintensity
asserts · one per region. it is the focal pointcomposition
wrap one hero card, a pricing panel, a focal surfacefixed mode as page chrome, inset from the viewport edgeone liquid surface per view. it is a wow moment, not dense product chromenever CSS-scale the frame; it measures its own boxa11y
the canvas layer isaria-hidden · wrapped content stays in normal flow, fully interactive · a :focus-visible inside wakes the ringjest-axedependencies
motion · ogl · sula-core and sula-motion from the same packagelive demo · try it out
a frame that answers
drag the cursor along the edge, or focus the button, and the border leans toward you
customize
radiuscorner radius in px, drives the box too
thicknessband width in px
insetgap between the frame and the edge
shine0 flat matte, 1 full neon rim
fluidoff paints the static accent border
introone-time reveal ramp on mount
usage
import { SulaFrame } from "usva/sula/sula-frame";
<SulaFrame
radius={20}
inset={5}
className="bg-surface px-10 py-9"
>
<Pricing />
</SulaFrame>props
| prop | type | default | notes |
|---|---|---|---|
| children | ReactNode | — | wrapped content, in normal flow above the canvas. optional in fixed mode. |
| fixed | boolean | false | false wraps its own box; true is a position:fixed frame around the viewport. |
| radius | number | — | corner radius in px. wrapper mode reads the box's computed border-radius; fixed mode scales with viewport width. |
| thickness | number | 2 | band width in px. |
| inset | number | 0 | gap between the frame and the edge in px. the viewport margin in fixed mode. |
| fluid | boolean | true | false mounts no canvas; reduced motion paints the static border instead. |
| intro | boolean | true | a one-time reveal ramp on mount. skipped under reduced motion. |
| accentColor | string | accent token | rim light and glow. |
| 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, 1 is the full neon rim. |
get it
npx shadcn add https://usva.build/r/sula-frame.jsonsource · components/ui/frame-geometry.tsexactly what this command copies
import { clamp01, easeOutCubic, smoothstep } from "./curves";
/** Band width in CSS px when the consumer passes nothing. */
export const DEFAULT_THICKNESS = 2;
/** Corner radius for a fixed viewport frame when none is given, before the
* width scale. */
export const DEFAULT_RADIUS = 16;
/** CSS-px radius of the disc that goos toward the cursor. */
export const BLOB_RADIUS = 26;
/** CSS-px smooth-min radius merging the pointer disc into the band. Wide enough
* that the neck reads as surface tension, not a butt-join. */
export const BLOB_K = 42;
/** Idle edge undulation, always on while the frame is visible. */
export const WOBBLE_REST = 0.6;
/** Extra edge displacement added at full hover/focus energy. */
export const WOBBLE_ENERGY = 3.0;
/** Within this CSS distance of the band the pointer goo is at full strength. */
export const EDGE_NEAR = 8;
/** Past this CSS distance the pointer goo has faded out entirely. */
export const EDGE_FAR = 120;
/** Per-frame easing of the pointer position and the energy scalar. */
export const POINTER_EASE = 0.16;
export const ENERGY_EASE = 0.09;
/** Perimeter highlight travel, in turns per second. */
export const SWEEP_SPEED = 0.12;
/** Duration of the one-time intro reveal, in seconds. */
export const INTRO_DELAY_SECONDS = 0.1;
export const INTRO_SECONDS = 4;
/** A rounded-box ring in CSS space: centre, half-extents, corner radius. */
export interface Ring {
cx: number;
cy: number;
hx: number;
hy: number;
r: number;
}
export interface IntroFrame {
progress: number;
radius: number;
}
/** Geometry for the one-time edge-to-frame flow. The shell front itself is
* resolved in the shader; the CPU grows the corners on the same eased clock. */
export function introFrame(ring: Ring, progress: number): IntroFrame {
const eased = easeOutCubic(clamp01(progress));
return {
progress: eased,
radius: ring.r * (0.18 + 0.82 * eased),
};
}
/** The same ring flattened to device px with Y flipped, ready for the shader. */
export interface PackedRing {
center: [number, number];
half: [number, number];
radius: number;
}
/**
* Signed distance to a rounded box (Inigo Quilez, MIT). Negative inside, zero on
* the edge, positive outside. Ported to JS so the pointer gate and the tests can
* reason about the same edge the shader draws.
*/
export function sdRoundBox(
px: number,
py: number,
bx: number,
by: number,
r: number,
): number {
const qx = Math.abs(px) - bx + r;
const qy = Math.abs(py) - by + r;
const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0));
return Math.min(Math.max(qx, qy), 0) + outside - r;
}
/**
* Corner radius for a fixed viewport frame: a gentle scale with width, clamped so
* a phone still rounds and an ultrawide does not turn into a stadium. Mirrors the
* frame radius the source LiquidBorder used.
*/
export function fixedRadius(width: number): number {
return Math.min(Math.max(38, width * 0.02), 78);
}
/**
* Resolves the corner radius from, in order: an explicit prop, the wrapped box's
* computed border-radius, or a default (width-scaled in fixed mode). Never
* negative.
*/
export function resolveRadius(options: {
explicit?: number;
computed?: number;
fixed: boolean;
width: number;
}): number {
const { explicit, computed, fixed, width } = options;
if (typeof explicit === "number") return Math.max(0, explicit);
if (fixed) return fixedRadius(width);
if (typeof computed === "number") return Math.max(0, computed);
return DEFAULT_RADIUS;
}
/**
* The ring inscribed in a canvas of the given CSS size, pulled in by `inset` and
* with the radius clamped to what the half-extents can hold.
*/
export function frameRing(box: {
width: number;
height: number;
inset: number;
radius: number;
}): Ring {
const hx = Math.max(box.width / 2 - box.inset, 1);
const hy = Math.max(box.height / 2 - box.inset, 1);
return {
cx: box.width / 2,
cy: box.height / 2,
hx,
hy,
r: Math.max(0, Math.min(box.radius, hx, hy)),
};
}
/** Flattens a CSS-space ring to device px, flipping Y for `gl_FragCoord`. */
export function packRing(
ring: Ring,
dpr: number,
canvasHeight: number,
): PackedRing {
return {
center: [ring.cx * dpr, (canvasHeight - ring.cy) * dpr],
half: [ring.hx * dpr, ring.hy * dpr],
radius: ring.r * dpr,
};
}
/**
* How hard the pointer goos the nearest edge: `presence` (0 away, 1 hovering)
* gated by proximity to the band, so a cursor drifting through the middle of a
* wrapped card never raises a blob, and one grazing the edge raises a full one.
*/
export function pointerStrength(
px: number,
py: number,
ring: Ring,
presence: number,
): number {
const dist = Math.abs(
sdRoundBox(px - ring.cx, py - ring.cy, ring.hx, ring.hy, ring.r),
);
const gate = 1 - smoothstep(EDGE_NEAR, EDGE_FAR, dist);
return clamp01(presence) * gate;
}
/** One pointer disc as a flat vec4 (x, y, radius, strength) in device px, Y flipped. */
export function packBlob(
px: number,
py: number,
radiusCss: number,
strength: number,
dpr: number,
canvasHeight: number,
): [number, number, number, number] {
return [px * dpr, (canvasHeight - py) * dpr, radiusCss * dpr, strength];
}
/** Idle-plus-energy edge amplitude in CSS px for a given energy scalar. */
export function wobbleFor(energy: number): number {
return WOBBLE_REST + WOBBLE_ENERGY * clamp01(energy);
}
source · components/ui/sula-frame.tsxexactly what this command copies
"use client";
import { useReducedMotion } from "motion/react";
import * as React from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { createBorderField } from "./border";
import {
type FieldColors,
liftTint,
resolveColor,
shineForBackdrop,
} from "./field";
import { useContextRecovery } from "./recovery";
import { clamp01 } from "./curves";
import {
BLOB_K,
BLOB_RADIUS,
DEFAULT_RADIUS,
DEFAULT_THICKNESS,
ENERGY_EASE,
frameRing,
INTRO_DELAY_SECONDS,
INTRO_SECONDS,
introFrame,
POINTER_EASE,
packBlob,
packRing,
pointerStrength,
resolveRadius,
SWEEP_SPEED,
wobbleFor,
} from "./frame-geometry";
export interface SulaFrameProps extends React.HTMLAttributes<HTMLDivElement> {
/** false wraps its own box; true is a position:fixed viewport frame. */
fixed?: boolean;
/** Corner radius in px. Wrapper mode defaults to the box's computed
* border-radius; fixed mode to a width scale. */
radius?: number;
/** Band width in px. Defaults to 2. */
thickness?: number;
/** Gap between the frame and the edge in px. Defaults to 0. */
inset?: number;
/** false mounts no canvas; reduced motion paints the static border. */
fluid?: boolean;
/** One-time reveal ramp on mount. Skipped under reduced motion. Defaults true. */
intro?: boolean;
accentColor?: string;
backdrop?: string;
tint?: string;
shine?: number;
children?: React.ReactNode;
}
const MAX_DPR = 2;
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");
const base: FieldColors = {
backdrop,
tint: resolveColor(tintToken || token("--usva-surface")),
accent: resolveColor(overrides.accent ?? token("--usva-accent")),
shine: overrides.shine ?? shineForBackdrop(backdrop),
};
return { ...base, tint: liftTint(base.tint, base.accent) };
}
/** Parses the top-left corner radius of an element, in px. */
function computedRadius(node: HTMLElement): number {
const value = Number.parseFloat(getComputedStyle(node).borderTopLeftRadius);
return Number.isFinite(value) ? value : 0;
}
export const SulaFrame = React.forwardRef<HTMLDivElement, SulaFrameProps>(
(
{
fixed = false,
radius,
thickness = DEFAULT_THICKNESS,
inset = 0,
fluid = true,
intro = true,
accentColor,
backdrop,
tint,
shine,
className,
style,
children,
...props
},
forwardedRef,
) => {
const reduced = useReducedMotion();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const layerRef = React.useRef<HTMLDivElement | null>(null);
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const thicknessRef = React.useRef(thickness);
thicknessRef.current = thickness;
const insetRef = React.useRef(inset);
insetRef.current = inset;
const radiusRef = React.useRef(radius);
radiusRef.current = radius;
const introRef = React.useRef(intro);
introRef.current = intro;
const overrides = React.useMemo(
() => ({ backdrop, tint, accent: accentColor, shine }),
[backdrop, tint, accentColor, shine],
);
const overridesRef = React.useRef(overrides);
overridesRef.current = overrides;
const fieldRef = React.useRef<ReturnType<typeof createBorderField>>(null);
const { failed, generation, onContextLost, onContextReady } =
useContextRecovery(canvasRef);
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
const animated = fluid && !reduced && !failed && mounted;
const staticBorder = mounted && (!fluid || reduced || failed);
const keepCanvas = fluid && !reduced && mounted;
// biome-ignore lint/correctness/useExhaustiveDependencies: `generation` is not read here, it is what rebuilds the field on a restored context
React.useEffect(() => {
if (!animated) return;
const canvas = canvasRef.current;
const layer = layerRef.current;
const container = containerRef.current;
const measureNode = fixed ? layer : container;
if (!canvas || !layer || !measureNode) return;
const field = createBorderField({
canvas,
colors: readColors(measureNode, overridesRef.current),
onContextLost,
});
if (!field) {
onContextLost();
return;
}
fieldRef.current = field;
onContextReady();
const refreshColors = () =>
field.setColors(readColors(measureNode, overridesRef.current));
let width = 0;
let height = 0;
let dpr = 1;
const measure = (): boolean => {
const w = fixed
? window.innerWidth
: Math.ceil(measureNode.getBoundingClientRect().width);
const h = fixed
? window.innerHeight
: Math.ceil(measureNode.getBoundingClientRect().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;
};
const pointer = { x: -1e5, y: -1e5 };
let pointerTarget = 0;
let presence = 0;
let focusTarget = 0;
let focus = 0;
const ringFor = () =>
frameRing({
width,
height,
inset: insetRef.current,
radius: resolveRadius({
explicit: radiusRef.current,
computed: fixed
? undefined
: computedRadius(measureNode as HTMLElement),
fixed,
width,
}),
});
const draw = (elapsed: number) => {
presence += (pointerTarget - presence) * POINTER_EASE;
focus += (focusTarget - focus) * ENERGY_EASE;
const introActive = introRef.current;
const introT = introActive
? clamp01((elapsed - INTRO_DELAY_SECONDS) / INTRO_SECONDS)
: 1;
const energy = clamp01(Math.max(presence, focus));
const ring = ringFor();
const introGeometry = introFrame(ring, introT);
const packed = packRing(
{ ...ring, r: introGeometry.radius },
dpr,
height,
);
const strength = pointerStrength(pointer.x, pointer.y, ring, presence);
const blobs = new Array<number>(2 * 4).fill(0);
let blobCount = 0;
if (strength > 0.01) {
const b = packBlob(
pointer.x,
pointer.y,
BLOB_RADIUS,
strength,
dpr,
height,
);
blobs[0] = b[0];
blobs[1] = b[1];
blobs[2] = b[2];
blobs[3] = b[3];
blobCount = 1;
}
field.draw({
center: packed.center,
half: packed.half,
radius: packed.radius,
thickness: (thicknessRef.current / 2) * dpr,
wobble: wobbleFor(Math.max(energy, 1 - introGeometry.progress)) * dpr,
energy,
sweep: (elapsed * SWEEP_SPEED) % 1,
time: elapsed,
blobs,
blobCount,
blobK: BLOB_K * dpr,
intro: introGeometry.progress,
});
};
if (!measure()) return;
let raf = 0;
let elapsed = 0;
let last = performance.now();
let killed = false;
const tick = () => {
if (killed) return;
const now = performance.now();
elapsed += (now - last) / 1000;
last = now;
draw(elapsed);
raf = requestAnimationFrame(tick);
};
const stop = () => cancelAnimationFrame(raf);
const run = () => {
last = performance.now();
stop();
raf = requestAnimationFrame(tick);
};
const pointerHost = fixed ? window : container;
const onMove = (event: Event) => {
const e = event as PointerEvent;
if (fixed) {
pointer.x = e.clientX;
pointer.y = e.clientY;
} else {
const box = (container as HTMLElement).getBoundingClientRect();
pointer.x = e.clientX - box.left;
pointer.y = e.clientY - box.top;
}
pointerTarget = 1;
};
const onLeave = () => {
pointerTarget = 0;
};
pointerHost?.addEventListener("pointermove", onMove as EventListener);
pointerHost?.addEventListener("pointerleave", onLeave);
const onFocusIn = (event: FocusEvent) => {
const target = event.target as HTMLElement | null;
if (target?.matches?.(":focus-visible")) focusTarget = 1;
};
const onFocusOut = () => {
if (!container?.querySelector(":focus-visible")) focusTarget = 0;
};
if (!fixed) {
container?.addEventListener("focusin", onFocusIn);
container?.addEventListener("focusout", onFocusOut);
}
let visible = true;
const io =
typeof IntersectionObserver === "undefined"
? null
: new IntersectionObserver((entries) => {
visible = entries[0]?.isIntersecting ?? true;
if (visible && !document.hidden) run();
else stop();
});
io?.observe(measureNode);
const onVisibility = () => {
if (document.hidden || !visible) stop();
else run();
};
document.addEventListener("visibilitychange", onVisibility);
const ro =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(() => measure());
if (fixed) window.addEventListener("resize", measure);
else ro?.observe(measureNode);
const themeObserver =
typeof MutationObserver === "undefined"
? null
: new MutationObserver(() => refreshColors());
themeObserver?.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "class"],
});
run();
return () => {
killed = true;
pointerHost?.removeEventListener(
"pointermove",
onMove as EventListener,
);
pointerHost?.removeEventListener("pointerleave", onLeave);
container?.removeEventListener("focusin", onFocusIn);
container?.removeEventListener("focusout", onFocusOut);
io?.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("resize", measure);
ro?.disconnect();
themeObserver?.disconnect();
stop();
field.dispose();
fieldRef.current = null;
canvas.style.width = "";
canvas.style.height = "";
};
}, [animated, fixed, generation]);
React.useEffect(() => {
const measureNode = fixed ? layerRef.current : containerRef.current;
if (!measureNode) return;
fieldRef.current?.setColors(readColors(measureNode, overrides));
}, [overrides, fixed]);
const staticRingStyle: React.CSSProperties = {
position: fixed ? "fixed" : "absolute",
inset: `${inset}px`,
borderRadius: fixed ? `${radius ?? DEFAULT_RADIUS}px` : "inherit",
border: `${thickness}px solid color-mix(in oklab, var(--usva-accent) 55%, transparent)`,
boxShadow:
"0 0 24px color-mix(in oklab, var(--usva-accent) 22%, transparent)",
};
const layer = keepCanvas ? (
<div
ref={layerRef}
aria-hidden="true"
className={cn(
fixed
? "pointer-events-none fixed inset-0 z-[2147483647]"
: "pointer-events-none absolute inset-0 z-10",
!animated && "hidden",
)}
>
<canvas ref={canvasRef} className="block h-full w-full" />
</div>
) : null;
const ring = staticBorder ? (
<div
aria-hidden="true"
className={cn("pointer-events-none", fixed ? "z-[2147483647]" : "z-10")}
style={staticRingStyle}
/>
) : null;
if (fixed) {
return (
<div
ref={(node) => {
containerRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}}
data-fluid={animated ? "on" : "off"}
style={{ display: "contents" }}
{...props}
>
{layer ? createPortal(layer, document.body) : null}
{ring ? createPortal(ring, document.body) : null}
{children}
</div>
);
}
return (
<div
ref={(node) => {
containerRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}}
data-fluid={animated ? "on" : "off"}
className={cn("relative isolate", className)}
style={style}
{...props}
>
{layer}
{ring}
{children}
</div>
);
},
);
SulaFrame.displayName = "SulaFrame";