Document Frame
The shell every document surface shares: a rounded card with a toolbar row (file glyph, name, an actions cluster), an optional sidebar column, a scrollable body and the loading and error overlays with one shared copy, driven by the viewer's Alpine `loading` and `error` state.
Preview
Couldn't load this file
Check that the file exists and is a supported format.
<div class="h-72 w-full max-w-2xl" x-data="{ loading: false, error: false }">
<x-ui.document-frame title="quarterly-report.pdf" icon="pdf" body-label="{{ __('Document pages') }}">
<x-slot:meta>
<x-ui.badge variant="secondary">{{ __('12 pages') }}</x-ui.badge>
</x-slot:meta>
<x-slot:actions>
<x-ui.button variant="ghost" size="sm" type="button">{{ __('Download') }}</x-ui.button>
<x-ui.button variant="outline" size="sm" type="button">{{ __('Share') }}</x-ui.button>
</x-slot:actions>
<div class="mx-auto flex h-full max-w-md items-center justify-center rounded-md bg-background p-6 text-sm text-muted-foreground shadow-sm">
{{ __('The rendered document goes here.') }}
</div>
</x-ui.document-frame>
</div>
Installation
php artisan ui:add document-frame
Registry contract
php artisan ui:add document-frame
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/document-frame.blade.php
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Document Frame (`document-frame`)
The shell every document surface shares: a rounded card with a toolbar row (file glyph, name, an actions cluster), an optional sidebar column, a scrollable body and the loading and error overlays with one shared copy, driven by the viewer's Alpine `loading` and `error` state.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add document-frame
```
## Usage
```blade
<div class="h-72 w-full max-w-2xl" x-data="{ loading: false, error: false }">
<x-ui.document-frame title="quarterly-report.pdf" icon="pdf" body-label="{{ __('Document pages') }}">
<x-slot:meta>
<x-ui.badge variant="secondary">{{ __('12 pages') }}</x-ui.badge>
</x-slot:meta>
<x-slot:actions>
<x-ui.button variant="ghost" size="sm" type="button">{{ __('Download') }}</x-ui.button>
<x-ui.button variant="outline" size="sm" type="button">{{ __('Share') }}</x-ui.button>
</x-slot:actions>
<div class="mx-auto flex h-full max-w-md items-center justify-center rounded-md bg-background p-6 text-sm text-muted-foreground shadow-sm">
{{ __('The rendered document goes here.') }}
</div>
</x-ui.document-frame>
</div>
```
## Props
- `title` (string|null, default `null`) — File name or label in the toolbar. With no title, actions or meta the toolbar row is omitted.
- `icon` (file|pdf|document|spreadsheet|grid|table|scissors|folder|none, default `file`) — The glyph before the title.
- `loading` (string|false, default `loading`) — Name of the Alpine state that shows the spinner overlay and sets aria-busy; false drops the overlay.
- `error` (string|false, default `error`) — Name of the Alpine state that shows the error overlay; false drops the overlay.
- `loadingText` (string|null, default `null`) — Copy under the spinner. Defaults to a translated 'Loading…'.
- `errorTitle` (string|null, default `null`) — Error overlay heading. Defaults to a translated "Couldn't load this file".
- `errorText` (string|null, default `null`) — Error overlay hint. Defaults to a translated format hint.
- `bodyLabel` (string|null, default `null`) — When set, the body becomes a focusable scroll region with this accessible name.
- `scroll` (bool, default `true`) — The body scrolls its content; false clips it so a child grid can own the scrolling.
- `padded` (bool, default `true`) — Inset the body with the 16px padding.
- `muted` (bool, default `true`) — Tint the body with the muted surface; false keeps the card surface under a table or grid.
## Use when
- Use when users need to upload, inspect, edit, annotate, or sign real documents as part of the workflow.
- Building a viewer or editor for a file (PDF, spreadsheet, CSV, Word document) that needs a titled card, a toolbar and loading and error states.
- Giving several document surfaces the same shell so they read as one product.
## Avoid when
- Do not use a heavy document interface when the same task can be completed more simply in structured product UI.
- Framing ordinary page content; card and section are the general surfaces.
- Showing a file as a small preview tile; file-thumbnail renders the glyph and name alone.
## Anti-patterns
- Heavy document UI when structured fields are simpler
## Rules
- Use the `<brok:document-frame>` tag (or `<x-ui.document-frame>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/document-frame
- Registry JSON (files, props, contract): https://brokui.dev/r/open/document-frame.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
<div class="h-72 w-full max-w-2xl" x-data="{ loading: false, error: 'HTTP 404' }">
<x-ui.document-frame title="missing.xlsx" icon="spreadsheet" error-text="{{ __('Check that the file exists and is a valid .xlsx file.') }}">
<div class="mx-auto h-full max-w-md rounded-md bg-background shadow-sm"></div>
</x-ui.document-frame>
</div>
<div class="h-72 w-full max-w-2xl" x-data="{ loading: true, error: false }">
<x-ui.document-frame title="quarterly-report.pdf" icon="pdf" loading-text="{{ __('Loading document…') }}">
<div class="mx-auto h-full max-w-md rounded-md bg-background shadow-sm"></div>
</x-ui.document-frame>
</div>
Long Content
<div class="h-72 w-full max-w-xs" x-data="{ loading: false, error: false }">
<x-ui.document-frame title="a-deliberately-long-file-name-that-keeps-going-and-going-until-it-must-truncate.docx" icon="document">
<x-slot:meta>
<x-ui.badge variant="secondary">{{ __('48 pages') }}</x-ui.badge>
</x-slot:meta>
<x-slot:actions>
<x-ui.button variant="ghost" size="sm" type="button">{{ __('Download') }}</x-ui.button>
</x-slot:actions>
<p class="rounded-md bg-background p-4 text-sm leading-relaxed text-foreground shadow-sm">
{{ __('A deliberately long body that verifies wrapping, overflow and content expansion inside the scrollable frame body without clipping anything the reader needs, line after line, until the frame must scroll rather than grow.') }}
{{ __('A deliberately long body that verifies wrapping, overflow and content expansion inside the scrollable frame body without clipping anything the reader needs, line after line, until the frame must scroll rather than grow.') }}
</p>
</x-ui.document-frame>
</div>
<div class="h-80 w-full max-w-3xl" x-data="{ loading: false, error: false }">
<x-ui.document-frame title="contract.pdf" icon="pdf" body-label="{{ __('Document pages') }}">
<x-slot:sidebar>
<nav aria-label="{{ __('Page thumbnails') }}" class="p-2">
<ul role="list" class="flex flex-col gap-2">
@foreach ([1, 2, 3] as $page)
<li>
<button
type="button"
class="flex w-full flex-col items-center gap-2 rounded-md border border-border bg-background p-2 text-sm text-muted-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring {{ $page === 1 ? 'ring-2 ring-primary' : '' }}"
@if ($page === 1) aria-current="page" @endif
>
<span class="h-16 w-12 rounded-sm bg-muted"></span>
{{ __('Page') }} {{ $page }}
</button>
</li>
@endforeach
</ul>
</nav>
</x-slot:sidebar>
<x-slot:footer>{{ __('Page 1 of 3') }}</x-slot:footer>
<div class="mx-auto flex h-full max-w-md items-center justify-center rounded-md bg-background p-6 text-sm text-muted-foreground shadow-sm">
{{ __('Page 1') }}
</div>
</x-ui.document-frame>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | null | null | File name or label in the toolbar. With no title, actions or meta the toolbar row is omitted. |
| icon | file | pdf | document | spreadsheet | grid | table | scissors | folder | none | file | The glyph before the title. |
| loading | string | false | loading | Name of the Alpine state that shows the spinner overlay and sets aria-busy; false drops the overlay. |
| error | string | false | error | Name of the Alpine state that shows the error overlay; false drops the overlay. |
| loadingText | string | null | null | Copy under the spinner. Defaults to a translated 'Loading…'. |
| errorTitle | string | null | null | Error overlay heading. Defaults to a translated "Couldn't load this file". |
| errorText | string | null | null | Error overlay hint. Defaults to a translated format hint. |
| bodyLabel | string | null | null | When set, the body becomes a focusable scroll region with this accessible name. |
| scroll | bool | true | The body scrolls its content; false clips it so a child grid can own the scrolling. |
| padded | bool | true | Inset the body with the 16px padding. |
| muted | bool | true | Tint the body with the muted surface; false keeps the card surface under a table or grid. |
Slots
default— The document body.actions— Controls at the end of the toolbar row (paging, zoom, search).meta— Badges or counts after the title.sidebar— A start-side column beside the body (outline, thumbnails, folders).footer— A status row under the body.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Reads the reactive `loading` and `error` names from the Alpine scope on its root; outside any scope both overlays stay hidden.
- Sets aria-busy on the root while loading; the loading overlay is a polite status region and the error overlay an alert.
- Declares registry capability flags: a11y, authoredStateFixtures, responsive, rtl, darkMode, localized.
Guidance
Upload, inspect, edit, annotate, or sign a real document.
Use when
- Use when users need to upload, inspect, edit, annotate, or sign real documents as part of the workflow.
- Building a viewer or editor for a file (PDF, spreadsheet, CSV, Word document) that needs a titled card, a toolbar and loading and error states.
- Giving several document surfaces the same shell so they read as one product.
Avoid when
- Do not use a heavy document interface when the same task can be completed more simply in structured product UI.
- Framing ordinary page content; card and section are the general surfaces.
- Showing a file as a small preview tile; file-thumbnail renders the glyph and name alone.
Use instead
- Structured product form
- Direct data entry
Anti-patterns
- Heavy document UI when structured fields are simpler
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
managed
- The loading overlay is role=status with aria-live=polite; the error overlay is role=alert.
- A `bodyLabel` makes the scroll region keyboard reachable and named.
- Logical properties keep the sidebar on the start side under dir="rtl".
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="document-frame-{{ $record->id }}">
<div class="h-72 w-full max-w-2xl" x-data="{ loading: false, error: false }">
<x-ui.document-frame title="quarterly-report.pdf" icon="pdf" body-label="{{ __('Document pages') }}">
<x-slot:meta>
<x-ui.badge variant="secondary">{{ __('12 pages') }}</x-ui.badge>
</x-slot:meta>
<x-slot:actions>
<x-ui.button variant="ghost" size="sm" type="button">{{ __('Download') }}</x-ui.button>
<x-ui.button variant="outline" size="sm" type="button">{{ __('Share') }}</x-ui.button>
</x-slot:actions>
<div class="mx-auto flex h-full max-w-md items-center justify-center rounded-md bg-background p-6 text-sm text-muted-foreground shadow-sm">
{{ __('The rendered document goes here.') }}
</div>
</x-ui.document-frame>
</div>
</div>
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'title' => null,
'icon' => 'file',
'loading' => 'loading',
'error' => 'error',
'loadingText' => null,
'errorTitle' => null,
'errorText' => null,
'bodyLabel' => null,
'scroll' => true,
'padded' => true,
'muted' => true,
])
{{--
Document Frame — the one shell every document surface shares: a rounded
card, a toolbar row (file glyph, name, an `actions` cluster at the end), an
optional `sidebar` column, a scrollable body, and the loading / error
overlays with one shared copy.
A viewer puts its Alpine component on the frame root and the frame reads
two reactive names from that scope:
loading — true while the file is fetched or drawn; shows the spinner
overlay and sets aria-busy on the root.
error — truthy once the file could not be loaded; shows the error
overlay (a string is fine, the copy stays the frame's own).
Pass other names with `loading="busy"` / `error="failed"`, or `:loading="false"`
to drop an overlay. Outside any Alpine scope both overlays stay hidden.
<x-ui.document-frame x-data="uiPdfViewer(…)" :title="$name" icon="pdf">
<x-slot:actions>…toolbar buttons…</x-slot:actions>
<x-slot:sidebar>…thumbnails…</x-slot:sidebar>
…the document…
</x-ui.document-frame>
Slots: default (the body), `actions` (end of the toolbar), `meta` (badges
after the name), `sidebar` (a start-side column), `footer`.
The body carries data-slot="document-frame-body" so a viewer can measure it
(`this.$el.querySelector('[data-slot="document-frame-body"]')`).
`bodyLabel` makes the body a focusable, labelled scroll region; `scroll`
(false clips instead of scrolling), `padded` and `muted` (false keeps the
card surface under a table or grid) shape it.
--}}
@php
// A viewer passes its own data-slot; the browser keeps only the first of
// two identical attributes, so read the override out of the bag and print
// exactly one.
$slotName = (string) ($attributes->get('data-slot') ?: 'document-frame');
$attributes = $attributes->except('data-slot');
$glyphs = [
'file' => 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6',
'pdf' => 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M9 13h6M9 17h4',
'document' => 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8',
'spreadsheet' => 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M8 13h2M8 17h2M14 13h2M14 17h2',
'grid' => 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM3 9h18M3 15h18M9 3v18M15 3v18',
'table' => 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM3 9h18M3 15h18M9 9v12',
'scissors' => 'M6 3a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM6 15a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM20 4 8.12 15.88M14.47 14.48 20 20M8.12 8.12 12 12',
'folder' => 'M4 20h16a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1h-7.5l-2-2H4a1 1 0 0 0-1 1v13a1 1 0 0 0 1 1Z',
];
$glyph = $icon === 'none' ? null : ($glyphs[$icon] ?? $glyphs['file']);
$loadingState = is_string($loading) && $loading !== '' ? $loading : null;
$errorState = is_string($error) && $error !== '' ? $error : null;
$showToolbar = filled($title) || isset($actions) || isset($meta);
$loadingText ??= __('Loading…');
$errorTitle ??= __("Couldn't load this file");
$errorText ??= __('Check that the file exists and is a supported format.');
$bodyClasses = 'relative flex-1 '.($muted ? 'bg-muted/40 ' : 'bg-card ').($scroll ? 'overflow-auto' : 'overflow-hidden').($padded ? ' p-4' : '');
@endphp
<div
data-slot="{{ $slotName }}"
@if ($loadingState) x-bind:aria-busy="typeof {{ $loadingState }} !== 'undefined' && {{ $loadingState }} ? 'true' : null" @endif
{{ $attributes->merge(['class' => 'flex h-full w-full flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground']) }}
>
@if ($showToolbar)
<div
data-slot="document-frame-toolbar"
class="flex flex-wrap items-center gap-2 border-b border-border bg-muted/30 px-3 py-2"
>
@if ($glyph)
<svg
class="size-4 shrink-0 text-muted-foreground"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
>
<path d="{{ $glyph }}" />
</svg>
@endif
@if (filled($title))
<span
data-slot="document-frame-title"
class="min-w-0 truncate text-sm font-medium text-foreground"
title="{{ $title }}"
>{{ $title }}</span>
@endif
@isset($meta)
<div data-slot="document-frame-meta" class="flex min-w-0 items-center gap-2">{{ $meta }}</div>
@endisset
@isset($actions)
<div data-slot="document-frame-actions" class="ms-auto flex flex-wrap items-center gap-1">{{ $actions }}</div>
@endisset
</div>
@endif
<div class="flex min-h-0 flex-1">
@isset($sidebar)
<aside
data-slot="document-frame-sidebar"
class="flex w-56 shrink-0 flex-col overflow-auto border-e border-border bg-card"
>
{{ $sidebar }}
</aside>
@endisset
<div
data-slot="document-frame-body"
@if (filled($bodyLabel)) tabindex="0" aria-label="{{ $bodyLabel }}" @endif
class="{{ $bodyClasses }}"
>
@if ($loadingState)
<div
data-slot="document-frame-loading"
class="absolute inset-0 z-10 flex items-center justify-center bg-card/60 backdrop-blur-sm"
x-show="typeof {{ $loadingState }} !== 'undefined' && {{ $loadingState }}"
x-cloak
role="status"
aria-live="polite"
>
<div class="flex flex-col items-center gap-3 text-muted-foreground">
<x-ui.spinner size="lg" />
<span class="text-sm">{{ $loadingText }}</span>
</div>
</div>
@endif
@if ($errorState)
<div
data-slot="document-frame-error"
class="absolute inset-0 z-10 flex items-center justify-center bg-card p-6"
x-show="typeof {{ $errorState }} !== 'undefined' && {{ $errorState }}"
x-cloak
role="alert"
>
<div class="flex max-w-sm flex-col items-center gap-3 text-center">
<svg
class="size-10 text-muted-foreground"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6M12 18v.01M12 11v3" />
</svg>
<p class="text-sm font-medium text-foreground">{{ $errorTitle }}</p>
<p class="text-sm text-muted-foreground">{{ $errorText }}</p>
</div>
</div>
@endif
{{ $slot }}
</div>
</div>
@isset($footer)
<div
data-slot="document-frame-footer"
class="flex items-center gap-2 border-t border-border px-3 py-2 text-sm text-muted-foreground"
>
{{ $footer }}
</div>
@endisset
</div>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.1 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0