Frame

Displays a customizable SVG frame for buttons, modals, and UI components.
Installation
Expand

npx @left4code/cosmic-ui-cli@latest add frame
 
Expand

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 (it detects Vue automatically).

Install the following dependencies:

pnpm
npm
yarn
bun
yarn add @left4code/svg-renderer

Or copy and paste the code below by hand.

components/ui/frame.vue
Expand

<script lang="ts">
import type { Paths } from "@left4code/svg-renderer";

const pathsCache = new Map<string, Paths>();

/**
 * `JSON.parse` a path definition, reusing the same array for the same string.
 *
 * `Frame` keys its effect on the `paths` reference, so parsing inline in a
 * render body hands it a brand-new array every render and tears the renderer
 * down and back up each time. Landing that in the middle of an enter animation
 * makes the new renderer miss `animationstart`, leaving the frame drawn for the
 * mid-animation (zoomed) box. Going through the cache keeps the reference
 * stable, so the effect runs once per mount.
 */
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;
}

export { parsePaths, parseFrameSet };
</script>

<script setup lang="ts">
import { computed, ref, watch, onUnmounted } from "vue";
import { twMerge } from "tailwind-merge";
import { setupSvgRenderer } from "@left4code/svg-renderer";

const props = defineProps<{
  class?: string;
  // 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;
}>();

const resolvedPaths = computed(() => props.paths ?? (props.frames && props.frame ? props.frames[props.frame] : undefined));

const svgRef = ref<SVGSVGElement | null>(null);
let instance: ReturnType<typeof setupSvgRenderer> | null = null;

watch(
  [svgRef, resolvedPaths],
  ([el, paths]) => {
    instance?.destroy();
    instance = null;
    if (el && el.parentElement && paths) {
      instance = setupSvgRenderer({
        el,
        paths,
        enableBackdropBlur: props.enableBackdropBlur,
        enableViewBox: props.enableViewBox,
      });
    }
  },
  { immediate: true }
);

onUnmounted(() => instance?.destroy());
</script>

<template>
  <svg
    ref="svgRef"
    xmlns="http://www.w3.org/2000/svg"
    :class="twMerge(['absolute inset-0 size-full', props.class])"
  />
</template>
 
Expand

Update the import paths to match your project setup.

Usage
Expand

import Frame, { parsePaths } from "@/components/ui/frame.vue";
 
Expand
Expand

<script setup lang="ts">
import Frame, { parsePaths } from "@/components/ui/frame.vue";

const framePaths = 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"]]}]'
);
</script>

<template>
  <Frame class="drop-shadow-2xl drop-shadow-primary/50" :paths="framePaths" />
</template>
 
Expand
Frame sets

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 onepaths literal per usage site.

Expand

<script setup lang="ts">
import Frame, { parseFrameSet } from "@/components/ui/frame.vue";

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"]]}]}'
);
</script>

<template>
  <div class="flex gap-2">
    <div class="relative size-24 [--color-frame-1-stroke:var(--color-primary)]">
      <Frame :frames="topbarFrames" frame="left-wedge" />
    </div>
    <div class="relative size-24 [--color-frame-1-stroke:var(--color-accent)]">
      <Frame :frames="topbarFrames" frame="right-wedge" />
    </div>
  </div>
</template>
 
Expand
Powered by synthetic caffeine · Deployed by Left4code · Signal traceable on GitHub.