<div
className={twMerge([
"relative size-90",
"[--color-frame-1-stroke:var(--color-primary)]/50",
"[--color-frame-1-fill:var(--color-primary)]/20",
"[--color-frame-2-stroke:var(--color-accent)]",
"[--color-frame-2-fill:var(--color-accent)]/20",
"[--color-frame-3-stroke:var(--color-accent)]",
"[--color-frame-3-fill:var(--color-accent)]/20",
"[--color-frame-4-stroke:var(--color-accent)]",
"[--color-frame-4-fill:var(--color-accent)]/20",
"[--color-frame-5-stroke:var(--color-primary)]/23",
"[--color-frame-5-fill:transparent]",
])}
>
<Frame
className="drop-shadow-2xl drop-shadow-primary/50"
paths={parsePaths(
'[{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-1-stroke)","fill":"var(--color-frame-1-fill)"},"path":[["M","37","12"],["L","0% + 59","12"],["L","0% + 85","0% + 33"],["L","79","0% + 12"],["L","50% - 3","12"],["L","50% + 16","30"],["L","100% - 35","30"],["L","100% - 16","47"],["L","100% - 16","100% - 47.05882352941177%"],["L","100% - 8","100% - 44.85294117647059%"],["L","100% - 9","100% - 16.666666666666668%"],["L","100% - 17","100% - 14.705882352941176%"],["L","100% - 17","100% - 30"],["L","100% - 34","100% - 12"],["L","50% + 13","100% - 12"],["L","50% + 15","100% - 26"],["L","50% - 11","100% - 12"],["L","37","100% - 12"],["L","19","100% - 30"],["L","19","0% + 50.490196078431374%"],["L","10","0% + 48.529411764705884%"],["L","10","0% + 20.098039215686274%"],["L","0% + 19.000000000000004","0% + 18.38235294117647%"],["L","19","29"],["L","37","12"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-2-stroke)","fill":"var(--color-frame-2-fill)"},"path":[["M","50% + 10","15"],["L","50% + 19","15"],["L","50% + 24","0% + 20"],["L","50% + 16","0% + 20"],["L","50% + 10","15"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-3-stroke)","fill":"var(--color-frame-3-fill)"},"path":[["M","50% + 25","15"],["L","50% + 34","15"],["L","50% + 40","0% + 21"],["L","50% + 31","0% + 21"],["L","50% + 25","15"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-4-stroke)","fill":"var(--color-frame-4-fill)"},"path":[["M","50% + 40","15"],["L","50% + 52","15"],["L","50% + 61","0% + 23"],["L","50% + 49","0% + 23"],["L","50% + 40","15"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-5-stroke)","fill":"var(--color-frame-5-fill)"},"path":[["M","36","3"],["L","0% + 58","0"],["L","0% + 84","0% + 40"],["L","81","0% + 0"],["L","50% - 1","4"],["L","50% + 5","6"],["L","50% + 54","7"],["L","50% + 74","23"],["L","100% - 32","21"],["L","100% - 8","42"],["L","100% - 9","100% - 52.450980392156865%"],["L","100% + 0","100% - 50.245098039215684%"],["L","100% + 0","100% - 15.196078431372548%"],["L","100% - 7","100% - 13.480392156862745%"],["L","100% - 7","100% - 27"],["L","100% - 29","100% - 3"],["L","50% + 14","100% + 0"],["L","50% + 21","100% - 31"],["L","50% - 13","100% + 0"],["L","37","100% - 4"],["L","11","100% - 28"],["L","10","0% + 55.3921568627451%"],["L","0","0% + 52.94117647058823%"],["L","1","0% + 18.627450980392158%"],["L","11","0% + 16.666666666666668%"],["L","11","25"],["L","36","3"]]}]'
)}
/>
</div>
npx @left4code/cosmic-ui-cli@latest add frame
This writes the component and everything it imports, then installs the packages it needs. Run npx @left4code/cosmic-ui-cli@latest initfirst if you haven't already.
Install the following dependencies:
Or copy and paste the code below by hand.
import { useRef, useEffect, useLayoutEffect } from "react";
import { twMerge } from "tailwind-merge";
import { type Paths, setupSvgRenderer } from "@left4code/svg-renderer";
// Draw before the browser paints, so there's never a frame where the text
// is visible but the border isn't. Falls back to useEffect during SSR,
// where useLayoutEffect would otherwise warn (there's no browser to paint
// into on the server anyway).
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
const pathsCache = new Map<string, Paths>();
function parsePaths(json: string): Paths {
const cached = pathsCache.get(json);
if (cached) return cached;
if (pathsCache.size > 200) pathsCache.clear();
const parsed = JSON.parse(json) as Paths;
pathsCache.set(json, parsed);
return parsed;
}
// Several Frames designed together as one composite (e.g. a topbar made of
// a left wedge, a pill, a search box, and a right wedge), keyed by name.
// Each entry is a plain Paths array - a set is just a bundle for
// convenience, since every path's position is already relative to its own
// Frame's box, never to its siblings.
type FrameSet = Record<string, Paths>;
const frameSetCache = new Map<string, FrameSet>();
function parseFrameSet(json: string): FrameSet {
const cached = frameSetCache.get(json);
if (cached) return cached;
if (frameSetCache.size > 200) frameSetCache.clear();
const parsed = JSON.parse(json) as FrameSet;
frameSetCache.set(json, parsed);
return parsed;
}
function Frame({
className,
paths,
frames,
frame,
enableBackdropBlur,
enableViewBox,
...props
}: {
// A single Frame's paths. Omit this and pass frames + frame instead to
// pick one out of a set designed together.
paths?: Paths;
frames?: FrameSet;
frame?: string;
enableBackdropBlur?: boolean;
enableViewBox?: boolean;
} & React.ComponentProps<"svg">) {
const resolvedPaths = paths ?? (frames && frame ? frames[frame] : undefined);
const svgRef = useRef<SVGSVGElement | null>(null);
useIsomorphicLayoutEffect(() => {
if (svgRef.current && svgRef.current.parentElement && resolvedPaths) {
const instance = setupSvgRenderer({
el: svgRef.current,
paths: resolvedPaths,
enableBackdropBlur,
enableViewBox,
});
return () => instance.destroy();
}
}, [resolvedPaths]);
return (
<svg
{...props}
className={twMerge(["absolute inset-0 size-full pointer-events-none", className])}
xmlns="http://www.w3.org/2000/svg"
ref={svgRef}
data-frame-paths={JSON.stringify(resolvedPaths)}
data-frame-backdrop-blur={enableBackdropBlur || undefined}
data-frame-view-box={enableViewBox || undefined}
/>
);
}
export { Frame, parsePaths, parseFrameSet };
Update the import paths to match your project setup. The data-frame-* attributes are only meaningful on this docs site: a small inline script in our own layout reads them to draw every frame's border before React loads at all, so the chrome isn't bare text for however long the framework bundle takes to arrive on a slow connection. That matters for frameworks that server-render (Next.js, Remix, and Astro with client:load like this site) - without a script like that reading them, the attributes are inert. Safe to leave in, or delete if you don't need them.
import { Frame, parsePaths } from "@/components/ui/frame";
<Frame
className="drop-shadow-2xl drop-shadow-primary/50"
paths={parsePaths(
'[{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-1-stroke)","fill":"var(--color-frame-1-fill)"},"path":[["M","37","12"],["L","0% + 59","12"],["L","0% + 85","0% + 33"],["L","79","0% + 12"],["L","50% - 3","12"],["L","50% + 16","30"],["L","100% - 35","30"],["L","100% - 16","47"],["L","100% - 16","100% - 47.05882352941177%"],["L","100% - 8","100% - 44.85294117647059%"],["L","100% - 9","100% - 16.666666666666668%"],["L","100% - 17","100% - 14.705882352941176%"],["L","100% - 17","100% - 30"],["L","100% - 34","100% - 12"],["L","50% + 13","100% - 12"],["L","50% + 15","100% - 26"],["L","50% - 11","100% - 12"],["L","37","100% - 12"],["L","19","100% - 30"],["L","19","0% + 50.490196078431374%"],["L","10","0% + 48.529411764705884%"],["L","10","0% + 20.098039215686274%"],["L","0% + 19.000000000000004","0% + 18.38235294117647%"],["L","19","29"],["L","37","12"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-2-stroke)","fill":"var(--color-frame-2-fill)"},"path":[["M","50% + 10","15"],["L","50% + 19","15"],["L","50% + 24","0% + 20"],["L","50% + 16","0% + 20"],["L","50% + 10","15"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-3-stroke)","fill":"var(--color-frame-3-fill)"},"path":[["M","50% + 25","15"],["L","50% + 34","15"],["L","50% + 40","0% + 21"],["L","50% + 31","0% + 21"],["L","50% + 25","15"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-4-stroke)","fill":"var(--color-frame-4-fill)"},"path":[["M","50% + 40","15"],["L","50% + 52","15"],["L","50% + 61","0% + 23"],["L","50% + 49","0% + 23"],["L","50% + 40","15"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-5-stroke)","fill":"var(--color-frame-5-fill)"},"path":[["M","36","3"],["L","0% + 58","0"],["L","0% + 84","0% + 40"],["L","81","0% + 0"],["L","50% - 1","4"],["L","50% + 5","6"],["L","50% + 54","7"],["L","50% + 74","23"],["L","100% - 32","21"],["L","100% - 8","42"],["L","100% - 9","100% - 52.450980392156865%"],["L","100% + 0","100% - 50.245098039215684%"],["L","100% + 0","100% - 15.196078431372548%"],["L","100% - 7","100% - 13.480392156862745%"],["L","100% - 7","100% - 27"],["L","100% - 29","100% - 3"],["L","50% + 14","100% + 0"],["L","50% + 21","100% - 31"],["L","50% - 13","100% + 0"],["L","37","100% - 4"],["L","11","100% - 28"],["L","10","0% + 55.3921568627451%"],["L","0","0% + 52.94117647058823%"],["L","1","0% + 18.627450980392158%"],["L","11","0% + 16.666666666666668%"],["L","11","25"],["L","36","3"]]}]'
)}
/>
Several Frames designed together as one composite - e.g. a topbar made of a left wedge, a pill, a search box, and a right wedge - export as one bundle (keyed by name) from the Cosmic UI editor's "Export all Frames" button. Parse it once with parseFrameSet, then pass frames + frame instead of paths to pick which one each instance renders. Every path's position is already relative to its own Frame's box, never to its siblings, so this changes nothing about how a given piece renders - it's just a way to carry several pieces in one constant instead of one paths literal per usage site.
const topbarFrames = parseFrameSet(
'{"left-wedge":[{"style":{"strokeWidth":"1","stroke":"var(--color-frame-1-stroke)","fill":"none"},"path":[["M","0","0"],["L","100%","0"],["L","100%","100%"],["L","0","100%"],["L","0","0"]]}],"right-wedge":[{"style":{"strokeWidth":"1","stroke":"var(--color-frame-1-stroke)","fill":"none"},"path":[["M","100%","0"],["L","0","0"],["L","0","100%"],["L","100%","100%"],["L","100%","0"]]}]}'
);
<div className="flex gap-2">
<div className="relative size-24 [--color-frame-1-stroke:var(--color-primary)]">
<Frame frames={topbarFrames} frame="left-wedge" />
</div>
<div className="relative size-24 [--color-frame-1-stroke:var(--color-accent)]">
<Frame frames={topbarFrames} frame="right-wedge" />
</div>
</div>