Component reference
PageFrame
A frame with fixed chrome and a body that takes the remaining space; the parent owns the chrome.
Preview
header, notice, messages and composer
One public composition for a small conversation screen: PageFrame owns the header, notice, scroll body and footer; AppBar carries identity and actions; ComposerPanel owns the writing surface.
render: () => (
<div
style={{
height: "min(680px, calc(100dvh - 90px))",
minHeight: 0,
width: "min(100%, 430px)",
margin: "0 auto",
overflow: "hidden",
border: "1px solid var(--ui-line-control)",
borderRadius: "14px",
}}
>
<PageFrame
variant="workspace"
header={
<AppBar
title="Studio MacBook"
status={<ChatStatus label="Offline · Draft saved" tone="waiting" />}
actions={[
<Button variant="quiet" size="small">
Reconnect
</Button>,
<IconButton label="Conversation actions">
<Icon name="menu" />
</IconButton>,
]}
/>
}
notice={
<Alert
layout="bar"
tone="warning"
actions={
<Button variant="quiet" size="small">
View receipt
</Button>
}
>
Last submission unverified; draft kept.
</Alert>
}
footer={
<ComposerPanel
placeholder="Type a message"
inputLabel="Message"
submitLabel="Send message"
submitContent={<Icon name="arrow-up" />}
>
<IconButton label="Add">
<Icon name="plus" />
</IconButton>
</ComposerPanel>
}
>
<ChatConversation ariaLabel="Conversation">
<ChatMessageList ariaLabel="Messages">
<ChatMessageListDate label="Today 20:40" />
<ChatMessage role="assistant" author="Assistant" timestamp="20:40">
<p>
You do not have to clear the desk completely. Keep a small “in progress” area so
things have a fixed home without having to dig them out every time.
</p>
<p>
Leave a book-sized area on the other side of your dominant hand for the two or three
books you are reading; keep the cable you are using in place and tuck the extra
length behind the desk.
</p>
</ChatMessage>
<ChatMessage role="user" author="You" timestamp="20:41">
<p>
A few more conditions: the desk is by the window and gets afternoon sun; I want my
usual pen, headphones and two current books within reach.
</p>
</ChatMessage>
<ChatMessage role="assistant" author="Assistant" timestamp="20:42">
<p>
That works. Keep the window side for the pen, headphones and those two books; split
cables into “in use / spare / archive” in three places.
</p>
</ChatMessage>
</ChatMessageList>
</ChatConversation>
</PageFrame>
</div>
)Installation
Usage
import { PageFrame } from "meowtail";Examples
The other stories that show this component.
Wrapping title and catalog filtering
The title, description, refresh action, and categories share their natural height; the body claims the remaining space on its own, and header controls are not clipped by percentages.
render() {
const selected = signal("all");
return (
<div style={{ height: "calc(100dvh - 90px)", minHeight: 0 }}>
<PageFrame
scrollOwner="layout"
header={
<Stack gap="compact">
<PageHeader
title="Extension catalog"
description="Browse authorized capabilities and pages; categories do not change the identity of the content."
actions={<Button variant="outline">Refresh catalog</Button>}
/>
<SegmentedControl
id="catalog-kind"
label="Catalog categories"
value={selected}
onValueChange={(value) => {
selected.value = value;
}}
options={[
{ value: "all", label: "All" },
{ value: "local", label: "Local" },
{ value: "enabled", label: "Enabled" },
]}
/>
</Stack>
}
>
<Stack>
{Array.from({ length: 20 }, (_, index) => (
<p>Catalog content {index + 1}: The main region keeps its own scroll range.</p>
))}
</Stack>
</PageFrame>
</div>
);
}Continuous reading and draft workspace
The page shell allocates the remaining space, a long draft scrolls inside the input, and the inline toolbar expansion and native popovers stay reachable.
render: () => (
<ChatFrame style={{ height: "calc(100dvh - 90px)", minHeight: 0 }}>
<PageFrame
variant="workspace"
toolbar={
<Inline gap="compact" style={{ position: "relative", zIndex: 2 }}>
<strong>Assistant</strong>
{/* A host's own inline expansion: a quiet trigger and a floating panel over the content. */}
<details style={{ position: "relative" }}>
<summary
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "4px 8px",
borderRadius: String(uiTokens.radius.md),
listStyle: "none",
color: String(uiTokens.ink.secondary),
fontWeight: 500,
cursor: "pointer",
}}
>
Current work
<Icon name="chevron-down" size={14} />
</summary>
<div
data-workspace-inline-panel
style={{
position: "absolute",
top: "calc(100% + 6px)",
zIndex: 2,
display: "grid",
justifyItems: "start",
gap: String(uiTokens.space.group),
width: "min(280px, 80vw)",
padding: String(uiTokens.space.section),
borderRadius: String(uiTokens.radius.xl),
background: String(uiTokens.surface.overlay),
boxShadow: String(uiTokens.shadow.float),
}}
>
<p style={{ margin: 0 }}>
Review the current work and its evidence, then close this and keep reading.
</p>
<Button size="small">View details</Button>
</div>
</details>
<Button variant="outline" size="small">
Stop
</Button>
</Inline>
}
header={
<span role="status">Shared layout example · Fixed content, no real model requests</span>
}
>
<div
ref={(host: HTMLElement) => {
const surface = mountRegionLayout(host, {
areas: [["messages"], ["compose"]],
rows: ["fill", "content"],
gap: "none",
regions: {
messages: { scrollOwner: "content" },
compose: {},
},
});
const draft = signal("");
const mounts = [
render(
<ChatConversation ariaLabel="Workspace messages">
{Array.from({ length: 30 }, (_, i) => (
<p>
Message {i + 1}: The same reading area keeps its position, so long content can
still be checked after the window changes.
</p>
))}
</ChatConversation>,
surface.regions.messages!,
),
render(
<ChatComposer
layout="stacked"
inputLabel="Workspace draft"
placeholder="What should we push forward together?"
value={draft}
onInput={(event: Event) => {
draft.value = (event.target as HTMLTextAreaElement).value;
}}
onSubmit={(event: Event) => event.preventDefault()}
trailing={
<ChoiceSelect
id="workspace-model"
label="Example model"
value="one"
placement="top"
align="end"
matchTriggerWidth={false}
options={[
{ value: "one", label: "Local example model" },
{ value: "two", label: "Another example model" },
]}
/>
}
submitLabel="Send"
>
<IconButton label="Add material">
<Icon name="plus" />
</IconButton>
</ChatComposer>,
surface.regions.compose!,
),
];
return () => {
for (const mount of mounts) mount.unmount();
surface.dispose();
};
}}
/>
</PageFrame>
</ChatFrame>
)Fixed header, form and dynamic body
One focused content page: keep the draft, inspect the checks, and finish with one action; a short narrow window still returns to the page header and never clips an overlay.
render: () => {
const expanded = signal(false);
const draft = signal("");
const feedback = signal("The local draft has not been checked yet.");
return (
<div style={{ height: "min(640px, calc(100dvh - 90px))" }} data-workspace-composition>
<PageFrame
variant="page"
scrollOwner="layout"
header={
<Container width="content">
<PageHeader
title="Check the draft"
description="Keep the current input, inspect the checks, and finish with one action."
/>
</Container>
}
>
<Container width="content">
<Stack gap="relaxed">
<Card>
<CardContent>
<Field
label="Composition draft"
htmlFor="workspace-composition-draft"
description="The same input node stays mounted when the window or grid changes."
>
<Input
id="workspace-composition-draft"
value={draft}
placeholder="Write the current composition draft"
onInput={(event: Event) => {
draft.value = (event.currentTarget as HTMLInputElement).value;
}}
/>
</Field>
</CardContent>
</Card>
<Stack gap="compact" data-workspace-composition-content>
<Inline justify="between" align="center">
<strong>Checks</strong>
<Toolbar label="Check controls">
<Popover
id="workspace-review-options"
trigger={(bindings) => (
<IconButton {...bindings} label="Check options">
<Icon name="settings" />
</IconButton>
)}
>
<Stack gap="compact" align="start">
<span>Keep checking the current draft after the window changes.</span>
<Button
size="small"
onClick={() => {
feedback.value = "Checked the current local draft.";
}}
>
Check current draft
</Button>
</Stack>
</Popover>
<Button
variant="quiet"
size="small"
onClick={() => {
expanded.value = !expanded.value;
}}
>
<Icon name="list-checks" />
{mapSignal(expanded, (value) =>
value ? "Collapse checks" : "Expand checks",
)}
</Button>
</Toolbar>
</Inline>
<ListGroup>
{mapSignal(expanded, (value) =>
Array.from({ length: value ? 28 : 10 }, (_, index) => (
<ListRow
title={`Check ${index + 1}`}
description="Dynamic content grows in the natural text flow; the body keeps scrolling while the page header stays put."
status={<Status state="success" label="Ready" />}
/>
)),
)}
</ListGroup>
</Stack>
<Inline as="footer">
<Button
onClick={() => {
feedback.value = "Checked the current local draft.";
}}
>
Check the draft at the end
</Button>
<span role="status">{feedback}</span>
</Inline>
</Stack>
</Container>
</PageFrame>
</div>
);
}API reference
Props
| Prop | Type | Default |
|---|---|---|
variant | "page" | "workspace" | "page" |
scrollOwner | "content" | "layout" | "content" |
header | Child | – |
toolbar | Child | – |
noticeA full-width status or alert row between the header and the body. | Child | – |
footerA persistent region after the body, typically a composer or action bar. | Child | – |
Also takes className, children and the attributes and events of its root element.
DOM parts
Registry
The component is registered as page-frame in the meowtail component registry.