Frame

Decorative border with advanced SVG path support.

import { Frame } from 'ui-lab-components/frame';
Corner Radius16
import type { CSSProperties } from 'react';
import { Frame } from 'ui-lab-components/frame';
 
const NOTCH_PATH = 'M 0 0 C 36 0 36 -44 90 -44 C 144 -44 144 0 180 0';
 
export default function Example() {
  return (
    <div className="flex h-full w-full p-12">
      <Frame
        path={NOTCH_PATH}
        pathWidth={180}
        side="top"
        shapeMode="indent"
        className="h-full w-full"
        style={{
          '--fill': 'var(--background-800)',
          '--stroke-color': 'var(--background-600)',
          '--frame-radius': '16px',
        } as CSSProperties}
      >
        <div className="flex h-full flex-col gap-3 p-6">
          <div className="h-3 w-1/2 rounded bg-background-600" />
          <div className="h-2.5 w-full rounded bg-background-700" />
          <div className="h-2.5 w-5/6 rounded bg-background-700" />
          <div className="h-2.5 w-2/3 rounded bg-background-700" />
        </div>
      </Frame>
    </div>
  );
}
 

frame/accent-callout

A left-accent-bar callout using edges="left" to stroke one edge while keeping its corner arcs, thickened and recolored via the frame's stroke custom properties.

Loading interactive preview
import type { CSSProperties } from 'react';
import { Frame } from 'ui-lab-components/frame';
import { Icon } from 'ui-lab-components/icon';
 
const CALLOUT_STYLE = {
  '--fill': 'var(--background-900)',
  '--stroke-color': 'var(--accent-500)',
  '--frame-stroke-width': '3px',
  '--frame-radius': '10px',
} as CSSProperties;
 
export default function Example() {
  return (
    <div>
      <Frame edges="left" strokeAlign="inside" style={CALLOUT_STYLE}>
        <div className="flex items-start gap-3 py-4 pl-5 pr-4">
          <Icon id="bulb" size={16} className="mt-0.5 shrink-0 text-accent-400" />
          <div>
            <p className="text-sm font-semibold text-foreground-50">Good to know</p>
            <p className="mt-1 text-sm leading-relaxed text-foreground-300">
              Pick a single edge and it still keeps its corner arcs — so a left
              accent bar reads as part of the card, not a rectangle glued to its
              side.
            </p>
          </div>
        </div>
      </Frame>
    </div>
  );
}
 

frame/animated-path

A bottom navigation bar whose central notch morphs from a compact create button into a wider action dock, keeping the cutout and outline synchronized with the controls.

Loading interactive preview
"use client";
 
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { Frame } from 'ui-lab-components/frame';
import { Icon } from 'ui-lab-components/icon';
 
type DockShape = {
  path: string;
  width: number;
  controlWidth: number;
};
 
const CONTROL_HEIGHT = 52;
const CLOSED_CONTROL_WIDTH = CONTROL_HEIGHT;
const OPEN_CONTROL_WIDTH = 188;
const CLEARANCE = 6;
const SHOULDER_WIDTH = 20;
const JOIN_ANGLE = Math.PI / 6;
 
const round = (value: number) => Number(value.toFixed(3));
 
// The notch follows the lower contour of the control at a constant offset.
// Its capsule's straight section has zero length when closed and grows with
// the control, so both states keep the same SVG command topology.
function dockShape(progress: number): DockShape {
  const controlWidth = CLOSED_CONTROL_WIDTH + (OPEN_CONTROL_WIDTH - CLOSED_CONTROL_WIDTH) * progress;
  const radius = CONTROL_HEIGHT / 2 + CLEARANCE;
  const straight = controlWidth - CONTROL_HEIGHT;
  const width = SHOULDER_WIDTH * 2 + radius * 2 + straight;
  const leftCenter = SHOULDER_WIDTH + radius;
  const rightCenter = leftCenter + straight;
 
  const joinX = leftCenter - radius * Math.cos(JOIN_ANGLE);
  const joinY = -radius * Math.sin(JOIN_ANGLE);
  const tangentX = Math.sin(JOIN_ANGLE);
  const tangentY = -Math.cos(JOIN_ANGLE);
  const shoulderHandle = 8;
  const arcHandle = (4 / 3) * Math.tan((Math.PI / 2 - JOIN_ANGLE) / 4) * radius;
 
  const leftJoinControlX = joinX - shoulderHandle * tangentX;
  const leftJoinControlY = joinY - shoulderHandle * tangentY;
  const leftArcControlX = joinX + arcHandle * tangentX;
  const leftArcControlY = joinY + arcHandle * tangentY;
 
  return {
    width,
    controlWidth,
    path: [
      'M 0 0',
      `C 9 0 ${round(leftJoinControlX)} ${round(leftJoinControlY)} ${round(joinX)} ${round(joinY)}`,
      `C ${round(leftArcControlX)} ${round(leftArcControlY)} ${round(leftCenter - arcHandle)} ${-radius} ${round(leftCenter)} ${-radius}`,
      `L ${round(rightCenter)} ${-radius}`,
      `C ${round(rightCenter + arcHandle)} ${-radius} ${round(width - leftArcControlX)} ${round(leftArcControlY)} ${round(width - joinX)} ${round(joinY)}`,
      `C ${round(width - leftJoinControlX)} ${round(leftJoinControlY)} ${round(width - 9)} 0 ${round(width)} 0`,
    ].join(' '),
  };
}
 
function useDockShape(open: boolean): DockShape {
  const progress = useRef(0);
  const [shape, setShape] = useState(() => dockShape(0));
 
  useEffect(() => {
    const target = open ? 1 : 0;
 
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      progress.current = target;
      setShape(dockShape(target));
      return;
    }
 
    const from = progress.current;
    const startedAt = performance.now();
    let frame = 0;
 
    const tick = (now: number) => {
      const elapsed = Math.min((now - startedAt) / 320, 1);
      const eased = 1 - Math.pow(1 - elapsed, 3);
      progress.current = from + (target - from) * eased;
      setShape(dockShape(progress.current));
 
      if (elapsed < 1) frame = requestAnimationFrame(tick);
    };
 
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [open]);
 
  return shape;
}
 
const NAV_STYLE = {
  '--fill': 'var(--background-900)',
  '--stroke-color': 'var(--background-600)',
  '--frame-radius': '18px',
} as CSSProperties;
 
const NAV_ITEMS = [
  { label: 'Home', icon: 'home' },
  { label: 'Alerts', icon: 'bell' },
  { label: 'Profile', icon: 'user' },
];
 
export default function ExpandableActionDock() {
  const [open, setOpen] = useState(false);
  const shape = useDockShape(open);
 
  return (
    <div className="relative pt-14">
      <Frame
        path={shape.path}
        pathWidth={shape.width}
        className="shadow-xl"
        style={NAV_STYLE}
      >
        <nav aria-label="Primary navigation" className="flex h-20 items-center px-3">
          <a
            href="#"
            aria-label="Home"
            className="flex min-w-0 flex-1 items-center justify-center text-accent-400"
          >
            <Icon id="home" size={18} />
          </a>
 
          <div
            style={{ width: shape.controlWidth + 24 }}
            className="shrink-0"
          />
 
          {NAV_ITEMS.slice(1).map(({ label, icon }) => (
            <a
              key={label}
              href="#"
              aria-label={label}
              className="flex min-w-0 flex-1 items-center justify-center text-foreground-400"
            >
              <Icon id={icon} size={18} />
            </a>
          ))}
        </nav>
      </Frame>
 
      {/* Controls in a cutout are siblings of Frame because its children are
          clipped to the animated silhouette. */}
      <div
        style={{ width: shape.controlWidth }}
        className="absolute left-1/2 top-14 flex h-13 -translate-x-1/2 -translate-y-1/2 items-center justify-center overflow-hidden rounded-full border border-accent-500 bg-accent-500 text-white shadow-lg"
      >
        <div
          className={`flex items-center gap-0.5 pr-9 transition-opacity duration-200 motion-reduce:transition-none ${open ? 'opacity-100' : 'pointer-events-none opacity-0'
            }`}
          aria-hidden={!open}
        >
          <button disabled={!open} type="button" className="flex items-center gap-2 rounded-full px-3 py-2 text-xs font-medium transition-colors hover:bg-white/15 active:bg-white/25 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white motion-reduce:transition-none">
            <Icon id="edit" size={14} />
            Post
          </button>
          <span className="h-5 w-px bg-white/30" />
          <button disabled={!open} type="button" className="flex items-center gap-2 rounded-full px-3 py-2 text-xs font-medium transition-colors hover:bg-white/15 active:bg-white/25 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white motion-reduce:transition-none">
            <Icon id="camera" size={14} />
            Photo
          </button>
        </div>
 
        <button
          type="button"
          aria-label={open ? 'Close create menu' : 'Open create menu'}
          aria-expanded={open}
          onClick={() => setOpen((value) => !value)}
          className={`absolute inset-y-0 flex aspect-square items-center justify-center rounded-full transition-[background-color,transform] duration-300 hover:bg-white/15 active:bg-white/25 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-white motion-reduce:transition-none ${open ? 'right-0 rotate-45' : 'left-0 rotate-0'
            }`}
        >
          <Icon id="plus" size={16} />
        </button>
      </div>
    </div>
  );
}
 

frame/bleed-media

A media card whose artwork runs edge to edge under a one-pixel border, with a dip scooped out of the bottom edge for the play button to sit in.

Loading interactive preview
import type { CSSProperties } from 'react';
import { Frame } from 'ui-lab-components/frame';
import { Icon } from 'ui-lab-components/icon';
 
// A dip scooped out of the bottom edge for the play button to sit in. Authored
// along the top edge as every Frame path is; `side="bottom"` rotates it onto
// the opposite edge, so the same path would work on any of the four.
const DIP_WIDTH = 96;
const DIP_PATH = 'M 0 0 C 18 0 18 -26 48 -26 C 78 -26 78 0 96 0';
 
// No `--fill` is needed: the artwork covers the frame edge to edge. Frame draws
// its outline *above* its children, so content that reaches the border cannot
// paint over the stroke's inner half — the hairline stays a hairline whether
// what sits under it is opaque, transparent or moving.
const CARD_STYLE = {
  '--stroke-color': 'var(--background-600)',
  '--frame-radius': '14px',
} as CSSProperties;
 
// Stand-in artwork. Gradients go through `style` rather than an arbitrary
// Tailwind class so the example carries its own colours wherever it is dropped.
const ARTWORK_STYLE: CSSProperties = {
  backgroundImage:
    'linear-gradient(135deg, var(--accent-600), var(--accent-300) 45%, var(--background-800))',
};
 
const SCRIM_STYLE: CSSProperties = {
  backgroundImage: 'linear-gradient(to top, var(--background-1000), transparent)',
};
 
export default function Example() {
  return (
    <div className="relative">
      <Frame
        side="bottom"
        path={DIP_PATH}
        pathWidth={DIP_WIDTH}
        style={CARD_STYLE}
      >
        {/* Bleeds to all four edges. The silhouette clips it, including around
            the dip, so there is no rounded-corner mismatch to patch up. */}
        <div className="relative aspect-video w-full" style={ARTWORK_STYLE}>
          <div className="absolute inset-x-0 bottom-0 h-2/3" style={SCRIM_STYLE} />
          <div className="absolute bottom-8 left-4 right-4">
            <p className="text-sm font-semibold text-foreground-50">Frames, end to end</p>
            <p className="text-xs text-foreground-300">4 min · Design systems</p>
          </div>
        </div>
      </Frame>
 
      {/* Cradled in the dip, half below the bottom edge — a sibling, like any
          content that belongs in a cutout. */}
      <div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2">
        <div className="flex size-11 items-center justify-center rounded-full bg-accent-500 text-white shadow-lg">
          <Icon id="play" size={16} className="ml-0.5" />
        </div>
      </div>
    </div>
  );
}
 

frame/corner-step

A step cut into a card's top-right corner with shapeAlign="end", where the path leaves off part-way down the next edge so the shape swallows the corner and hands back one of its own.

Loading interactive preview
"use client";
 
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { Frame } from 'ui-lab-components/frame';
import { Icon } from 'ui-lab-components/icon';
 
// A step cut into the top-right corner. `shapeAlign="end"` pins the path's far
// end to the corner instead of centring it on the edge, and the path is free to
// leave off part-way down the *next* edge — it eases to the notch floor, runs
// flat, then turns into the right edge and stops there. Frame closes the region
// back along the outside of the border either way, so the shape swallows the
// corner and hands the frame back one of its own.
//
// `radius` matches `--frame-radius` so the corner it returns is the same shape
// as the three it did not touch.
const STEP = { ease: 40, depth: 40, span: 104, radius: 12 };
const STEP_WIDTH = STEP.ease + STEP.span;
 
// The cubic's handles are horizontal at both ends, a quarter of the run in, so
// the curve leaves the top edge and meets the notch floor tangentially — that
// tangency is what makes the step read as a slope rather than a ledge.
const STEP_PATH = [
  'M 0 0',
  `c ${STEP.ease * 0.25} 0 ${STEP.ease * 0.75} ${-STEP.depth} ${STEP.ease} ${-STEP.depth}`,
  `L ${STEP_WIDTH - STEP.radius} ${-STEP.depth}`,
  `a ${STEP.radius} ${STEP.radius} 0 0 0 ${STEP.radius} ${-STEP.radius}`,
].join(' ');
 
const clamp = (value: number) => Math.min(1, Math.max(0, value));
const easeInOutSine = (value: number) =>
  (1 - Math.cos(Math.PI * clamp(value))) / 2;
 
function cornerStepPath(progress: number) {
  // Stagger the depth from right to left so the existing corner pulls the
  // shape inward instead of the whole top edge dropping at once.
  const rightDepth = STEP.depth * easeInOutSine(progress / 0.78);
  const middleDepth = STEP.depth * easeInOutSine((progress - 0.1) / 0.8);
  const leftDepth = STEP.depth * easeInOutSine((progress - 0.22) / 0.78);
 
  return [
    'M 0 0',
    `C ${STEP.ease * 0.25} 0 ${STEP.ease * 0.75} ${-leftDepth} ${STEP.ease} ${-leftDepth}`,
    `C 64 ${-leftDepth} 104 ${-middleDepth} ${STEP_WIDTH - STEP.radius} ${-rightDepth}`,
    `a ${STEP.radius} ${STEP.radius} 0 0 0 ${STEP.radius} ${-STEP.radius}`,
  ].join(' ');
}
 
function useCornerStep(active: boolean) {
  const progress = useRef(0);
  const [shape, setShape] = useState(() => ({ path: cornerStepPath(0), progress: 0 }));
 
  useEffect(() => {
    const target = active ? 1 : 0;
 
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      progress.current = target;
      setShape({ path: cornerStepPath(target), progress: target });
      return;
    }
 
    const from = progress.current;
    const startedAt = performance.now();
    let frame = 0;
 
    const tick = (now: number) => {
      const elapsed = Math.min((now - startedAt) / 480, 1);
      // A symmetric curve makes hover-in and hover-out exact time-reverses
      // without rushing either end of the morph.
      const eased = easeInOutSine(elapsed);
      const next = from + (target - from) * eased;
 
      progress.current = next;
      setShape({ path: cornerStepPath(next), progress: next });
      if (elapsed < 1) frame = requestAnimationFrame(tick);
    };
 
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [active]);
 
  return shape;
}
 
// The stroke is translucent on purpose. Frame collects the border and the
// shape's outline into one mask and colours it once, so the two lap over each
// other at the seam without compositing to a brighter knot — a see-through
// outline stays one even weight the whole way round.
const CARD_STYLE = {
  '--fill': 'var(--background-900)',
  '--stroke-color': 'color-mix(in oklch, var(--accent-500) 70%, transparent)',
  '--frame-radius': `${STEP.radius}px`,
} as CSSProperties;
 
function StaticCornerStep(): ReactNode {
  return (
    <div className="relative">
      <Frame
        side="top"
        shapeAlign="end"
        path={STEP_PATH}
        pathWidth={STEP_WIDTH}
        style={CARD_STYLE}
      >
        <div className="flex flex-col gap-2 px-5 pb-5 pt-4">
          <p className="font-semibold text-foreground-50">Priority routing</p>
          <p className="text-sm leading-relaxed text-foreground-300">
            The step is part of the outline, not a badge sitting on top of one —
            the fill and the content are clipped to it too.
          </p>
        </div>
      </Frame>
 
      {/* The label sits *in* the cut, so it is a sibling of the Frame: children
          are clipped to the silhouette and would be cut away here. */}
      <div
        className="pointer-events-none absolute right-0 top-0 flex items-center justify-center gap-1.5 text-xs font-medium text-accent-400"
        style={{ width: STEP.span, height: STEP.depth }}
      >
        <Icon id="bolt" size={12} />
        Featured
      </div>
    </div>
  );
}
 
function AnimatedCornerStep(): ReactNode {
  const [active, setActive] = useState(false);
  const shape = useCornerStep(active);
  const animatedStyle = {
    '--fill': 'var(--background-950)',
    '--stroke-color': `color-mix(in oklch, var(--background-700) ${100 - shape.progress * 100}%, var(--accent-500) ${shape.progress * 100}%)`,
    '--frame-radius': `${STEP.radius}px`,
  } as CSSProperties;
 
  return (
    <div
      className="relative isolate cursor-default rounded-xl outline-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent-500"
      tabIndex={0}
      onMouseEnter={() => setActive(true)}
      onMouseLeave={() => setActive(false)}
      onFocus={() => setActive(true)}
      onBlur={() => setActive(false)}
    >
      <Frame
        side="top"
        shapeAlign="end"
        path={shape.path}
        pathWidth={STEP_WIDTH}
        className="z-10"
        style={animatedStyle}
      >
        <div className="flex flex-col gap-2 px-5 pb-5 pt-4">
          <p className="font-semibold text-foreground-50">Hover to transform</p>
          <p className="text-sm leading-relaxed text-foreground-300">
            The original rounded corner continuously unfolds into the step.
          </p>
        </div>
      </Frame>
 
      <div
        className="pointer-events-none absolute right-0 top-0 z-0 flex items-center justify-center gap-1.5 text-xs font-medium text-accent-400"
        style={{
          width: STEP.span,
          height: STEP.depth,
        }}
      >
        <Icon id="bolt" size={12} />
        Animated
      </div>
    </div>
  );
}
 
export default function Example() {
  return (
    <div className="flex flex-col gap-8">
      <StaticCornerStep />
      <AnimatedCornerStep />
    </div>
  );
}
 

frame/dashed-dropzone

An upload zone using pathStroke="dashed" — an SVG stroke that stays crisp and evenly spaced around rounded corners where a CSS dashed border would not.

Loading interactive preview
import type { CSSProperties } from 'react';
import { Frame } from 'ui-lab-components/frame';
import { Icon } from 'ui-lab-components/icon';
 
// No path here — just the plain rounded frame with `pathStroke="dashed"`.
// The dash is an SVG stroke, so it stays crisp and evenly spaced around the
// corners at any size, unlike a CSS `border: dashed` on a rounded box.
const DROPZONE_STYLE = {
  '--fill': 'var(--background-900)',
  '--stroke-color': 'var(--background-600)',
  '--frame-radius': '16px',
} as CSSProperties;
 
export default function Example() {
  return (
    <div>
      <Frame pathStroke="dashed" style={DROPZONE_STYLE}>
        <div className="flex flex-col items-center gap-3 px-6 py-10 text-center">
          <span className="flex size-12 items-center justify-center rounded-full bg-background-800 text-accent-400">
            <Icon id="cloud" size={20} />
          </span>
          <div>
            <p className="text-sm font-medium text-foreground-100">
              Drop files to upload
            </p>
            <p className="mt-1 text-xs text-foreground-400">
              or <span className="text-accent-400">browse</span> — PNG, JPG up to 10MB
            </p>
          </div>
        </div>
      </Frame>
    </div>
  );
}
 

frame/tooltip

A popover whose pointer extends out of the frame as one continuous outline, using shapeMode="extend" and side to aim the tail at any edge.

Loading interactive preview
import type { CSSProperties } from 'react';
import { Frame } from 'ui-lab-components/frame';
import { Icon } from 'ui-lab-components/icon';
 
// `shapeMode="extend"` pushes the path *out* of the frame instead of carving
// into it, and the border wraps the extended shape — so the tail reads as one
// continuous outline of the bubble, not a separate triangle stuck underneath.
const TAIL_WIDTH = 48;
const TAIL_PATH = 'M 0 0 C 8 0 20 -16 24 -16 C 28 -16 36 0 48 0';
 
const TOOLTIP_STYLE = {
  '--fill': 'var(--background-800)',
  '--stroke-color': 'var(--background-600)',
  '--frame-radius': '12px',
} as CSSProperties;
 
export default function Example() {
  return (
    <div className="flex flex-col items-center">
      <Frame
        side="bottom"
        shapeMode="extend"
        path={TAIL_PATH}
        pathWidth={TAIL_WIDTH}
        className="shadow-xl"
        style={TOOLTIP_STYLE}
      >
        <div className="flex items-start gap-2.5 px-3.5 py-2.5">
          <Icon id="info" size={16} className="mt-0.5 shrink-0 text-accent-400" />
          <p className="text-sm leading-snug text-foreground-200">
            Point the tail at whatever it explains — flip{' '}
            <code className="rounded bg-background-950/60 px-1 text-foreground-100">
              side
            </code>{' '}
            to move it to any edge.
          </p>
        </div>
      </Frame>
 
      {/* The element the tooltip is anchored to. */}
      <button
        type="button"
        className="mt-2 rounded-md bg-background-800 px-3 py-1.5 text-sm text-foreground-300"
      >
        Hover target
      </button>
    </div>
  );
}