# Frame

Group related content in separated or connected panels with a shared header and footer.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. An agent skill is available at [/.well-known/agent-skills/site-skill.md](/.well-known/agent-skills/site-skill.md).





## Playground [#playground]

```tsx
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel, FrameFooter } from "@/components/ui/frame";

export const FrameDemo = () => (
  <Frame className="min-h-80 w-full max-w-md" variant="default" spacing="default" stacked={false} dense={false}>
    <FrameHeader>
      <FrameTitle>{"Workspace"}</FrameTitle>
      <FrameDescription>{"Manage your deployment environments."}</FrameDescription>
    </FrameHeader>
    <FramePanel fit={false}>
      <h4 className="text-sm font-medium">Deployment</h4>
      <p className="text-sm text-muted-foreground">Production is up to date.</p>
    </FramePanel>
    <FramePanel fit={false}>
      <h4 className="text-sm font-medium">Preview</h4>
      <p className="text-sm text-muted-foreground">Changes are ready for review.</p>
    </FramePanel>
    <FrameFooter>Last synced just now</FrameFooter>
  </Frame>
);
```

## Installation [#installation]

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/frame.json
```

## Dependencies [#dependencies]

Installed automatically by the registry CLI. For manual setup:

```bash
npm install cn@^0.4.0
```

## Usage [#usage]

```tsx
import {
  Frame,
  FrameHeader,
  FrameTitle,
  FrameDescription,
  FramePanel,
  FrameFooter,
} from "@/components/ui/frame";

<Frame>
  <FrameHeader>
    <FrameTitle>Workspace</FrameTitle>
    <FrameDescription>Your deployment environments.</FrameDescription>
  </FrameHeader>
  <FramePanel>Production is up to date.</FramePanel>
  <FramePanel>Preview is ready for review.</FramePanel>
  <FrameFooter>Updated just now</FrameFooter>
</Frame>;
```

Use Frame for related settings, status sections, or activity rows sharing one outer surface. It is layout only, not an accordion, app shell, or interactive card. Parts are optional; a Frame can contain a single panel or header/content/footer inside one panel.

## Examples [#examples]

### Separated panels [#separated-panels]

<ComponentPreview name="frame-demo" title="frame-demo.tsx">
  <FrameDemo />
</ComponentPreview>

The muted outer container groups card-colored panels. `spacing="sm" | "default" | "lg"` sets panel padding and header/footer horizontal padding to 12/16/24px, with an outer inset and gap of 2/4/8px. Header and footer vertical padding stays 12px. Each panel grows into spare height by default.

### Stacked and dense [#stacked-and-dense]

<ComponentPreview name="frame-stacked-demo" title="frame-stacked-demo.tsx">
  <FrameStackedDemo />
</ComponentPreview>

`stacked` joins adjacent **direct** FramePanel siblings with a shared border and square inner corners. Wrapping a panel or placing another element between panels interrupts joining. `dense` removes outer padding and gaps but preserves panel content padding. Use both for a flush connected list; dense alone does not merge panel seams. Neither option clips overflow or changes keyboard behavior.

### Ghost, custom radius and content-sized panels [#ghost-custom-radius-and-content-sized-panels]

<ComponentPreview name="frame-ghost-demo" title="frame-ghost-demo.tsx">
  <FrameGhostDemo />
</ComponentPreview>

`variant="ghost"` removes only the outer border. `FramePanel fit` disables that panel's flex growth; this is visible only when the frame has spare height. It does not force a fixed height or truncate content.

Set `className="[--frame-radius:var(--radius-lg)]"` on Frame to customize its outer radius. The default is `var(--radius-xl)`. Inner corners derive from the outer radius minus the inset and border, clamped to zero; dense panels use the outer radius. `--frame-panel-radius` can override the derived radius when necessary. These are CSS variables, not React props. Use classes on individual parts to override their padding or surfaces.

### Consumer setup and optional stories [#consumer-setup-and-optional-stories]

Requires React 19, Tailwind CSS 4 with preflight, `cn`, and shadcn-compatible `muted`, `foreground`, `card`, `card-foreground`, `border`, `muted-foreground`, and radius tokens. No provider, Base UI runtime, animation stylesheet, or client directive is needed. Frame can be imported from a Next.js Server Component. The CLI uses your configured UI alias; review overwrite prompts before replacing an existing file. For manual setup, install `cn` and copy the single Source file into your UI directory. Website demos and playgrounds are not distributed.

Install Frame first, then optionally install its portable stories:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/frame-stories.json
```

Use your existing `@storybook/react` with matching Storybook package versions (types and composition checked with 10.6.1). Discover `**/*.stories.tsx` in your UI directory and import your global Tailwind/theme stylesheet in the preview. No addons or providers are required. Stories install separately without reinstalling Frame, adding Storybook packages, or changing `.storybook/`. This project has no Storybook server; Storybook UI is not verified here.

## Behavior & Accessibility [#behavior--accessibility]

Frame and FramePanel render `div`; FrameHeader renders `header`, FrameTitle `h3`, FrameDescription `p`, and FrameFooter `footer`. Each forwards its corresponding native attributes, handlers, and React 19 ref. FrameTitle's `h3` must fit your page hierarchy; use another native heading inside FrameHeader when a different level is needed. Headers and footers inside sectioning contexts follow native landmark rules, so use an appropriate surrounding section rather than assuming automatic ARIA labeling.

There is no managed state, focus behavior, disabled state, live region, or motion. Add accessible labels and keyboard behavior to any controls you place inside panels. Titles and descriptions are not automatically associated with a region; supply application-owned IDs and ARIA attributes when needed. Panels wrap long text, but wide tables and code blocks should own horizontal scrolling. Focus rings and overflow are not clipped by Frame.

The playground controls configure the same markup as its runnable generated code. Reset restores all configuration; the demo has no transient interaction state. Copy includes all imports and safely serialized title/description content.

Compared with ReUI, FrameTitle is a native heading, and the supported spacing choices are `sm`, `default`, and `lg`. This is an independently implemented API, not a drop-in copy of every ReUI variant or CSS variable.

## Props [#props]

| Prop        | Type                                  | Default   | Description                                                                                                           |
| ----------- | ------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------- |
| children    | ReactNode                             | Not set   | Each part: native children. Keep panels as direct siblings for stacked layouts.                                       |
| className   | string                                | Not set   | Each part: merged classes. Set \[--frame-radius:var(--radius-lg)] on Frame to customize concentric corners.           |
| dense       | boolean                               | false     | Frame: removes outer padding and gaps, not panel content padding. Use stacked as well for shared panel seams.         |
| description | ReactNode (FrameDescription children) | Not set   | Demo content for FrameDescription children, not a Frame prop. Empty text omits the description in this example.       |
| fit         | boolean                               | false     | FramePanel only: disables flex growth. Visible when the frame has spare height; the playground uses a minimum height. |
| ref         | Ref\<HTMLElement> (element-specific)  | Not set   | Each part forwards its React 19 ref to its native element: div, header, h3, p or footer.                              |
| spacing     | "sm" \| "default" \| "lg"             | "default" | Frame: panel padding and header/footer horizontal padding (12/16/24px); outer inset and gap (2/4/8px).                |
| stacked     | boolean                               | false     | Frame: removes gaps and joins adjacent direct FramePanel siblings with one border. Wrappers interrupt joining.        |
| title       | ReactNode (FrameTitle children)       | Not set   | Demo content for FrameTitle children, not a Frame prop. FrameTitle renders an h3.                                     |
| variant     | "default" \| "ghost"                  | "default" | Frame: ghost removes only the outer border, preserving the muted surface and panel borders.                           |

Frame and FramePanel forward native div attributes, handlers and React 19 refs. FrameHeader uses header, FrameTitle h3, FrameDescription p and FrameFooter footer. Title and description playground controls configure children, not root props. No implicit ARIA relationships or interactive behavior are added.

## Source [#source]

```tsx
import { cn } from "cn";
import type { ComponentProps } from "react";

const spacingClasses = {
  default: "[--frame-space:1rem] [--frame-inset:0.25rem]",
  lg: "[--frame-space:1.5rem] [--frame-inset:0.5rem]",
  sm: "[--frame-space:0.75rem] [--frame-inset:0.125rem]",
} as const;

export type FrameProps = ComponentProps<"div"> & {
  variant?: "default" | "ghost";
  spacing?: keyof typeof spacingClasses;
  stacked?: boolean;
  dense?: boolean;
};

/** Layout only. Adjacent direct panels share a seam when stacked. */
export const Frame = ({
  className,
  variant = "default",
  spacing = "default",
  stacked = false,
  dense = false,
  ...props
}: FrameProps) => (
  <div
    data-slot="frame"
    data-variant={variant}
    data-spacing={spacing}
    data-stacked={stacked}
    data-dense={dense}
    className={cn(
      "flex min-w-0 flex-col rounded-(--frame-radius) bg-muted/50 text-foreground [--frame-radius:var(--radius-xl)] [--frame-panel-radius:max(0px,calc(var(--frame-radius)-var(--frame-inset)-1px))] gap-(--frame-inset) p-(--frame-inset)",
      spacingClasses[spacing],
      variant === "default" && "border border-border",
      variant === "ghost" &&
        "[--frame-panel-radius:max(0px,calc(var(--frame-radius)-var(--frame-inset)))]",
      stacked &&
        "gap-0 [&>[data-slot=frame-panel]:has(+[data-slot=frame-panel])]:rounded-b-none [&>[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none [&>[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0",
      dense &&
        "p-0 [--frame-inset:0rem] [--frame-panel-radius:var(--frame-radius)]",
      dense &&
        variant === "default" &&
        "[&>[data-slot=frame-panel]]:border-x-0 [&>[data-slot=frame-panel]:first-child]:border-t-0 [&>[data-slot=frame-panel]:last-child]:border-b-0",
      className
    )}
    {...props}
  />
);

export type FramePanelProps = ComponentProps<"div"> & { fit?: boolean };

export const FramePanel = ({
  className,
  fit = false,
  ...props
}: FramePanelProps) => (
  <div
    data-slot="frame-panel"
    data-fit={fit}
    className={cn(
      "min-w-0 rounded-(--frame-panel-radius) border border-border bg-card p-(--frame-space) text-card-foreground [overflow-wrap:anywhere]",
      !fit && "grow",
      className
    )}
    {...props}
  />
);

export const FrameHeader = ({
  className,
  ...props
}: ComponentProps<"header">) => (
  <header
    data-slot="frame-header"
    className={cn(
      "flex min-w-0 flex-col gap-1 px-(--frame-space) py-3 [overflow-wrap:anywhere]",
      className
    )}
    {...props}
  />
);

export const FrameTitle = ({
  children,
  className,
  ...props
}: ComponentProps<"h3">) => (
  <h3
    data-slot="frame-title"
    className={cn("text-sm font-semibold", className)}
    {...props}
  >
    {children}
  </h3>
);

export const FrameDescription = ({
  className,
  ...props
}: ComponentProps<"p">) => (
  <p
    data-slot="frame-description"
    className={cn("text-sm text-muted-foreground", className)}
    {...props}
  />
);

export const FrameFooter = ({
  className,
  ...props
}: ComponentProps<"footer">) => (
  <footer
    data-slot="frame-footer"
    className={cn(
      "flex min-w-0 flex-wrap items-center gap-2 px-(--frame-space) py-3 text-sm text-muted-foreground [overflow-wrap:anywhere]",
      className
    )}
    {...props}
  />
);

```

## Credits [#credits]

* [ReUI Frame](https://reui.io/docs/components/base/frame): API and composition inspiration. Independent Vandor implementation using native React elements and theme tokens, without copying ReUI source.
