Skip to content
Brok UI

Loading…

No results

Document Frame

Open source

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.

Version
v1.0.1
Stability
stable
License
MIT
Related
Spinner
File Thumbnail
Card

Preview

quarterly-report.pdf
12 pages
Loading…
The rendered document goes here.
previews.components.document-frame.default.blade.php 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>

Installation

terminal
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.

  • blade resources/views/components/ui/document-frame.blade.php
Registry dependencies
spinner
Packages
composer: jml/brok:^0.2

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.

document-frame.md
# 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

error.blade.php Blade
<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>
loading.blade.php Blade
<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.blade.php Blade
<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>
sidebar.blade.php Blade
<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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

document-frame-actions document-frame-body document-frame-error document-frame-footer document-frame-loading document-frame-meta document-frame-sidebar document-frame-title document-frame-toolbar {{ $slotName }}

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

Document workflow

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
document-frame document-frame-toolbar document-frame-title document-frame-meta document-frame-actions document-frame-sidebar document-frame-body document-frame-loading document-frame-error document-frame-footer
Theming hooks
document-frame

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/document-frame.blade.php Blade
@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