Frame
Widget shell — a card of rows sitting in a titled tray.
A card of rows sitting in a titled tray — the shell a widget sits in.
The tray shows along one edge only; the strip left above the card is the header. The card is flush to its left, right and bottom, and draws the hairlines between its own rows.
Installation
Frame ships with the library — no separate install.
import { Frame, Button, Switch, Text, Badge, Card, Chip, Avatar } from 'panelui-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add frameUsage
<Frame>
<Frame.Header>
<Frame.Title>Agent monitor</Frame.Title>
<Frame.Action>All agents under 25% token limit</Frame.Action>
</Frame.Header>
<Frame.Panel>
<Frame.Row>
<Frame.Content>
<Frame.Title>opus-4.6</Frame.Title>
<Frame.Description>Indexing the repository</Frame.Description>
</Frame.Content>
<Frame.Actions>
<Chip size="sm" variant="success">Running</Chip>
</Frame.Actions>
</Frame.Row>
</Frame.Panel>
</Frame>Composition
<Frame>
<Frame.Header>
<Frame.Title>…</Frame.Title>
<Frame.Action>…</Frame.Action>
</Frame.Header>
<Frame.Panel>
<Frame.Section title="…">
<Frame.Row>
<Frame.Media>…</Frame.Media>
<Frame.Content>
<Frame.Title>…</Frame.Title>
<Frame.Description>…</Frame.Description>
</Frame.Content>
<Frame.Actions>…</Frame.Actions>
</Frame.Row>
</Frame.Section>
</Frame.Panel>
</Frame>Frame.Header— The strip of shell above the panel, holding the title and the action.Frame.Title— Frame heading. Muted in a header; inside aFrame.Contentit is the row's subject instead, so it takes the foreground colour and truncates to one line.Frame.Action— Trailing slot on the header — a label, a button, a badge. Strings render muted.Frame.Description— Secondary line under a title, in a column-wrapped header or inside aFrame.Content.Frame.Panel— The card holding the rows. Divides them for you.Frame.Section— A labelled cluster of rows, for a panel holding more than one group.Frame.Row— A row inside the panel. Give it anonPressand it becomes a pressable.Frame.Media— Leading slot on a row — an icon, an avatar, a status dot. Holds its size.Frame.Content— The flexible middle of a row. Takes what the other two leave and is allowed to shrink past its content.Frame.Actions— Trailing slot on a row — a chip, a value, a switch, a small button. Holds its size.
Examples
A settings panel
Nothing has to say where the hairlines go — the panel puts one above every row but the first.
<Frame>
<Frame.Header>
<Frame.Title>Notifications</Frame.Title>
<Frame.Description>How we reach you.</Frame.Description>
</Frame.Header>
<Frame.Panel>
<Frame.Row>
<Text className="flex-1">Push</Text>
<Switch value={push} onValueChange={setPush} />
</Frame.Row>
<Frame.Row>
<Text className="flex-1">Email</Text>
<Switch value={email} onValueChange={setEmail} />
</Frame.Row>
<Frame.Row>
<Text className="flex-1">SMS</Text>
<Switch value={sms} onValueChange={setSms} />
</Frame.Row>
</Frame.Panel>
</Frame>A row that would not fit
Everything here is longer than the room it has. The media and the actions hold their size, the content column shrinks around them, and nothing is clipped — at any width, without measuring anything.
<Frame.Panel>
{deploys.map((deploy) => (
<Frame.Row key={deploy.id} align="start">
<Frame.Media>
<Avatar size="sm" fallback={deploy.initials} />
</Frame.Media>
<Frame.Content>
<Frame.Title>{deploy.message}</Frame.Title>
<Frame.Description>{deploy.detail}</Frame.Description>
</Frame.Content>
<Frame.Actions>
<Chip size="sm" variant="success">Live</Chip>
<Chip size="sm" variant="outline">{deploy.age}</Chip>
</Frame.Actions>
</Frame.Row>
))}
</Frame.Panel>A row of chips that wraps
wrap lets the row run onto a second line. For a cluster of tags, a second line beats one of them disappearing off the edge.
<Frame.Panel>
<Frame.Row wrap className="gap-2">
<Chip size="sm" variant="success">2 Running</Chip>
<Chip size="sm">1 Idle</Chip>
<Chip size="sm" variant="outline">1 Done</Chip>
<Frame.Actions className="ml-auto">
<Text size="xs" muted>15m12s ago</Text>
</Frame.Actions>
</Frame.Row>
</Frame.Panel>With a header action and a footer
<Frame>
<Frame.Header>
<Frame.Title>API keys</Frame.Title>
<Frame.Action>
<Button size="sm" variant="outline">New key</Button>
</Frame.Action>
</Frame.Header>
<Frame.Panel>
{keys.map((key) => (
<Frame.Row key={key.id}>
<Text>{key.name}</Text>
<Badge variant="outline">{key.lastUsed}</Badge>
</Frame.Row>
))}
</Frame.Panel>
</Frame>Rows that lead somewhere
A row with an onPress is a real pressable — press feedback and a button role — instead of a View with a handler bolted on. chevron says so before you tap it.
<Frame.Panel>
{sections.map((section) => (
<Frame.Row
key={section.id}
chevron
onPress={() => router.push(section.href)}
>
<Text size="sm" className="flex-1">{section.label}</Text>
</Frame.Row>
))}
</Frame.Panel>Sections inside a panel
For a widget with more than one cluster of rows. Each section labels its group and divides its own rows.
<Frame.Panel>
<Frame.Section title="General">
<Frame.Row>…</Frame.Row>
<Frame.Row>…</Frame.Row>
</Frame.Section>
<Frame.Section title="Danger zone">
<Frame.Row chevron onPress={confirmDelete}>
<Text size="sm" className="flex-1 text-destructive">
Delete workspace
</Text>
</Frame.Row>
</Frame.Section>
</Frame.Panel>Variants
variant
default(default)plain
{/* The default: a card flush inside a shell, with the header in the strip above it. */}
<Frame variant="default">…</Frame>
{/* No shell — for a Frame inside a card that already draws a border,
where the shell’s own edge just inside it reads as a double line. */}
<Card>
<Card.Content className="p-4">
<Frame variant="plain">
<Frame.Panel>…</Frame.Panel>
</Frame>
</Card.Content>
</Card>API Reference
Frame
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Frame.Root
| Prop | Type | Default | Description |
|---|---|---|---|
variant | FrameVariant | default | plain drops the outer shell so the panel is the widget — for a Frame inside a container that already draws its own border. |
Frame.Panel
| Prop | Type | Default | Description |
|---|---|---|---|
dividers | boolean | — | Set false to place the hairlines by hand instead — for a panel whose rows are generated somewhere the divider order is not obvious. |
Frame.Row
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
divided | boolean | — | Draw a hairline above this row. Frame.Panel sets it for you; pass it explicitly to override the panel's decision either way. |
chevron | boolean | — | Trailing chevron marking the row as leading somewhere. |
wrap | boolean | — | Let the row run onto a second line instead of holding one. For a cluster of chips or tags, where the alternative is the last ones being clipped. |
align | 'center' | 'start' | — | Where the row's slots sit against each other. start for a row two or three lines tall, where centring an icon against a tall text column leaves it floating in the middle. |
Frame.Section
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | — | Heading above the rows. Strings are wrapped for you. |
divided | boolean | — |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
Why the card is flush
The two surfaces are nested rather than stacked, and only one edge of the outer one is ever visible. The panel meets the shell's left, right and bottom exactly, so the shell reads as something the card is sitting in rather than as a border drawn around it — and the strip left at the top is the header, which is why the header needs no rule under it and no background of its own.
The shell's radius is the larger of the two, and the panel's top corners are tighter. That is the reverse of the usual nested-radius rule, and it is deliberate: with only the top corners free, matching them would make the two surfaces read as one misdrawn shape. The panel's bottom corners are not set at all — the shell clips them, so they take its radius exactly, which is what overflow-hidden on the root is doing.
Thickening the shell's border
That clip follows the shell's border box, not the box inside its border. Along the straight edges the panel is held off by the border width and the edge shows through, but at the corner arcs the panel's square corner is clipped to the outer radius and paints across the border. At the default hairline that is a sliver nobody sees. Give the shell a thicker border and the corners visibly eat it, so tell the panel where to stop:
<Frame className="rounded-[28px] border-2 border-dashed">
<Frame.Panel className="rounded-b-[26px]">…</Frame.Panel>
</Frame>The radius to use is the shell's less its border width. It is on you rather than on the component because both arrive as className strings, which Frame cannot read.
Dividers
React Native has no :first-child, so the hairline between rows cannot be a CSS rule. Frame.Panel places them instead — a line above every child but the first. Passing divided on a row overrides that either way, and dividers={false} on the panel hands the whole job back to you.
Frame.Section divides its own rows the same way, so the two nest without either needing to know about the other.
Why a row has slots
Yoga defaults flexShrink to 0, the opposite of the web. A child that is not told to shrink never does, so a fourth thing in a row pushes the others past the edge — where the frame's overflow-hidden cuts them off silently, rather than wrapping or truncating the way a browser would.
Frame.Media and Frame.Actions hold their size. Frame.Content takes what is left and carries min-w-0, which is the part that is easy to miss: a flex child's minimum size is its content unless told otherwise, so flex-1 alone still refuses to go narrower than the longest word inside it.
For a row that genuinely has too much in it — a handful of chips, say — wrap lets it take a second line instead. align="start" is for a row two or three lines tall, where centring an icon against a tall text column leaves it floating in the middle.
Frame.Row forwards ordinary view or Pressable props for the branch it renders, while retaining ownership of its row classes and—when interactive—its button role and primary press handler.
Public exports
Values: Frame
Types: FrameProps, FrameRootProps, FramePanelProps, FrameRowProps, FrameSectionProps, FrameHeaderProps, FrameActionProps, FrameMediaProps, FrameContentProps, FrameActionsProps, FrameVariant