Skip to content
Brok UI

Loading…

No results

Code Display

Open source

Read-only code surfaces — a highlighted code block, a single-line snippet, a tabbed CLI command block and a unified/split diff.

Version
v1.2.0
Stability
stable
License
MIT
Related
JSON Viewer
CSV Viewer
DOCX Viewer

Diff Viewer — A code diff visualiser with unified or split layouts, added/removed/context lines, a change summary and copy-to-clipboard.

Preview

counter.js
3 additions, 2 deletions
Removed: export function counter(start) {
Added: export function counter(start = 0) {
let count = start;
Removed: return () => count++;
Added: const next = () => ++count;
Added: return next;
}
previews.components.diff-viewer.default.blade.php Blade
@php
    $old = "export function counter(start) {\n  let count = start;\n  return () => count++;\n}";
    $new = "export function counter(start = 0) {\n  let count = start;\n  const next = () => ++count;\n  return next;\n}";
@endphp

<x-ui.diff-viewer
    :old-code="$old"
    :new-code="$new"
    old-title="counter.js"
    new-title="counter.js"
    layout="unified"
    class="w-full"
/>

Installation

terminal
php artisan ui:add diff-viewer

Registry contract

php artisan ui:add diff-viewer 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/diff-viewer.blade.php
Registry dependencies
copy-button
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.

diff-viewer.md
# Brok UI: Diff Viewer (`diff-viewer`)

A code diff visualiser with unified or split layouts, added/removed/context lines, a change summary and copy-to-clipboard.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add diff-viewer
```

## Usage

```blade
@php
    $old = "export function counter(start) {\n  let count = start;\n  return () => count++;\n}";
    $new = "export function counter(start = 0) {\n  let count = start;\n  const next = () => ++count;\n  return next;\n}";
@endphp

<x-ui.diff-viewer
    :old-code="$old"
    :new-code="$new"
    old-title="counter.js"
    new-title="counter.js"
    layout="unified"
    class="w-full"
/>
```

## Props

- `oldCode` (mixed|null, default `null`)
- `newCode` (mixed|null, default `null`)
- `lines` (mixed|null, default `null`) — A diff you already have: [['type' => context|add|del, 'text' => ...]]. A ['type' => 'skip', 'count' => n, 'text' => optional hunk header] row marks n lines left out between hunks: it renders as an 'n unchanged lines' separator and moves both line counters on by n.
- `layout` (string, default `unified`)
- `language` (mixed|null, default `null`)
- `oldTitle` (mixed|null, default `null`)
- `newTitle` (mixed|null, default `null`)
- `mode` (code|fields, default `code`) — code (default) is the line diff of oldCode/newCode or lines, unchanged. fields is a record diff: one row per field with its before and after value, a change summary and no copy button.
- `fields` (array|null, default `null`) — fields mode rows: [['label' or 'field', 'before', 'after', 'status', 'reason', 'size']]. status is added|removed|changed|skip|too_large (unchanged is an alias of skip); without one it is derived from the values. skip rows render muted with reason (default Unchanged or Not compared); too_large rows render reason (default Too large to show) and size instead of the value, which is never printed. Arrays and objects are shown as pretty JSON.
- `context` (int|null, default `null`) — code mode. Keeps this many unchanged lines next to each change and folds every longer unchanged run into an expandable 'N unchanged lines' row (a native details element, so it works without JavaScript). Runs shorter than two folded lines stay open. null shows every line.

## Use when

- Use for exact technical detail, code readability, diagnostics, and copyable reference material.
- Reviewing what changed between two versions of a file or snippet.
- Illustrating a migration in documentation, where before and after must be read together.

## Avoid when

- Do not surface developer-specific patterns in end-user flows unless the task genuinely requires them.
- Editing either side; the viewer is read-only. Use editor for an editable surface instead.
- Diffing large files; the whole diff is rendered server-side with no virtualisation.

## Anti-patterns

- Exposing developer tooling in end-user flows without a task need

## Rules

- Use the `<brok:diff-viewer>` tag (or `<x-ui.diff-viewer>`) 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/diff-viewer
- Registry JSON (files, props, contract): https://brokui.dev/r/open/diff-viewer.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

fields.blade.php Blade
{{-- mode="fields": a record diff with one row per field. Statuses are derived from the values unless given; a skipped row is muted with its reason, and a too-large value shows its size instead of its content. --}}
<x-ui.diff-viewer
    mode="fields"
    old-title="customer #4821"
    class="w-full"
    :fields="[
        ['label' => __('Name'), 'field' => 'name', 'before' => 'Jordan Riley', 'after' => 'Jordan Riley-Hart'],
        ['label' => __('Email'), 'field' => 'email', 'before' => 'jordan@example.com', 'after' => 'jordan.riley@example.com'],
        ['label' => __('Phone'), 'field' => 'phone', 'before' => null, 'after' => '+31 20 555 0134'],
        ['label' => __('Fax'), 'field' => 'fax', 'before' => '+31 20 555 0100', 'after' => null],
        ['label' => __('Plan'), 'field' => 'plan', 'before' => 'Team', 'after' => 'Team'],
        ['label' => __('Internal notes'), 'field' => 'notes', 'status' => 'skip', 'reason' => __('Not compared: private field')],
        ['label' => __('Contract scan'), 'field' => 'contract_pdf', 'status' => 'too_large', 'size' => '4.2 MB'],
    ]"
/>
folded.blade.php Blade
@php
    $old = implode("\n", [
        'import { createStore } from "./store";',
        '',
        'export function counter(start) {',
        '  const store = createStore();',
        '  let count = start;',
        '  store.on("reset", () => {',
        '    count = start;',
        '  });',
        '  store.on("step", (by) => {',
        '    count += by;',
        '  });',
        '  store.on("log", () => {',
        '    console.log(count);',
        '  });',
        '  return () => count++;',
        '}',
    ]);
    $new = str_replace(['export function counter(start) {', '  return () => count++;'], ['export function counter(start = 0) {', '  return () => ++count;'], $old);
@endphp

{{-- context: keep 2 unchanged lines around each change and fold the rest; a folded row opens in place. The second diff is pre-computed, with a skip row for the lines left out between hunks. --}}
<div class="flex w-full flex-col gap-4">
    <x-ui.diff-viewer :old-code="$old" :new-code="$new" old-title="counter.js" new-title="counter.js" :context="2" class="w-full" />

    <x-ui.diff-viewer
        language="js"
        class="w-full"
        :lines="[
            ['type' => 'context', 'text' => 'export function counter(start) {'],
            ['type' => 'skip', 'count' => 12, 'text' => '@@ -14,3 +14,3 @@'],
            ['type' => 'del', 'text' => '  return () => count++;'],
            ['type' => 'add', 'text' => '  return () => ++count;'],
            ['type' => 'context', 'text' => '}'],
        ]"
    />
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
oldCode mixed | null null
newCode mixed | null null
lines mixed | null null A diff you already have: [['type' => context|add|del, 'text' => ...]]. A ['type' => 'skip', 'count' => n, 'text' => optional hunk header] row marks n lines left out between hunks: it renders as an 'n unchanged lines' separator and moves both line counters on by n.
layout string unified
language mixed | null null
oldTitle mixed | null null
newTitle mixed | null null
mode code | fields code code (default) is the line diff of oldCode/newCode or lines, unchanged. fields is a record diff: one row per field with its before and after value, a change summary and no copy button.
fields array | null null fields mode rows: [['label' or 'field', 'before', 'after', 'status', 'reason', 'size']]. status is added|removed|changed|skip|too_large (unchanged is an alias of skip); without one it is derived from the values. skip rows render muted with reason (default Unchanged or Not compared); too_large rows render reason (default Too large to show) and size instead of the value, which is never printed. Arrays and objects are shown as pretty JSON.
context int | null null code mode. Keeps this many unchanged lines next to each change and folds every longer unchanged run into an expandable 'N unchanged lines' row (a native details element, so it works without JavaScript). Runs shorter than two folded lines stay open. null shows every line.

Slots

  • default —

Data slots

Stable hooks for CSS overrides and browser tests.

diff-viewer diff-viewer-field diff-viewer-fold diff-viewer-gap diff-viewer-reason diff-viewer-summary diff-viewer-{{ $side }}

Behavior

  • Pass oldCode and newCode to have the line diff computed, or pass lines to render a diff you already have.
  • layout="unified" stacks the change in one column; layout="split" puts the old and new sides next to each other.
  • The header states the added and removed counts, so the size of the change reads before the diff does.
  • context folds long unchanged runs in code mode into 'N unchanged lines' rows that open in place; pre-computed lines can mark a gap between hunks with a skip row.
  • mode="fields" renders a Field / Before / After grid that stacks into labelled cells on a narrow screen; the header summarises changed, added, removed and skipped counts.
  • Declares registry capability flags: a11y, responsive, rtl, darkMode, localized.

Guidance

Developer and documentation tool

Expose exact technical or diagnostic information.

Use when

  • Use for exact technical detail, code readability, diagnostics, and copyable reference material.
  • Reviewing what changed between two versions of a file or snippet.
  • Illustrating a migration in documentation, where before and after must be read together.

Avoid when

  • Do not surface developer-specific patterns in end-user flows unless the task genuinely requires them.
  • Editing either side; the viewer is read-only. Use editor for an editable surface instead.
  • Diffing large files; the whole diff is rendered server-side with no virtualisation.

Use instead

  • Plain-language product UI for nontechnical users

Anti-patterns

  • Exposing developer tooling in end-user flows without a task need
Anatomy
root
Theming hooks
diff-viewer diff-viewer-field diff-viewer-fold diff-viewer-gap diff-viewer-reason diff-viewer-summary diff-viewer-{{ $side }}

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
No component-owned keyboard interaction; native element behavior applies.
Focus
none
  • Added and removed lines carry a text marker as well as a colour, so the change survives a colour-vision difference; a screen reader hears 'Added:' or 'Removed:' before the line, and the +/- sign itself is aria-hidden. The spoken prefix is select-none, so it is not copied with a text selection.
  • Line-number gutters are marked aria-hidden and are not copied with the code. In the split layout an unchanged line is read once, from the left side.
  • A folded run is a native details/summary pair: the summary is a keyboard control that states how many lines it hides and exposes its expanded state.
  • In fields mode every row states its status (Added, Removed, Changed, Skipped, Not shown) and every cell its side (Before, After, Empty) in text, visually hidden where the column heading already shows it, so the change never depends on colour; the +/- signs are decorative.
  • 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

Safe

Livewire can update this component through forwarded wire:* attributes.

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/diff-viewer.blade.php Blade
@props([
    'oldCode' => null,
    'newCode' => null,
    'lines' => null,
    'layout' => 'unified',
    'language' => null,
    'oldTitle' => null,
    'newTitle' => null,
    // code (default): a line diff of oldCode/newCode or lines.
    // fields: a record diff, one row per field with its before and after value.
    'mode' => 'code',
    // fields mode: [['label' | 'field', 'before', 'after', 'status', 'reason', 'size']].
    'fields' => null,
    // code mode, opt-in: keep this many unchanged lines around each change and
    // fold longer unchanged runs into an expandable "N unchanged lines" row.
    // null (default) shows every line.
    'context' => null,
])

@php
    $layout = in_array($layout, ['unified', 'split'], true) ? $layout : 'unified';
    $mode = $mode === 'fields' ? 'fields' : 'code';
    $context = $context === null || $context === '' || $context === false ? null : max(0, (int) $context);

    // Fields mode: normalise each record field to one row. A row without a
    // status derives it: only an after value is added, only a before value is
    // removed, equal values are skipped as unchanged, anything else changed.
    // `skip` rows are shown muted with their reason; `too_large` rows show a
    // reason and size instead of the value, which is never printed.
    $fieldRows = [];
    $fieldCounts = ['changed' => 0, 'added' => 0, 'removed' => 0, 'skipped' => 0];
    if ($mode === 'fields') {
        $display = static function (mixed $value): string {
            return match (true) {
                $value === null => '',
                is_bool($value) => $value ? 'true' : 'false',
                is_scalar($value), $value instanceof \Stringable => (string) $value,
                default => (string) json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
            };
        };
        foreach ((array) $fields as $field) {
            if (! is_array($field)) {
                continue;
            }
            $before = $field['before'] ?? null;
            $after = $field['after'] ?? null;
            $status = $field['status'] ?? null;
            $unchanged = $status === 'unchanged';
            if (! in_array($status, ['added', 'removed', 'changed', 'skip', 'too_large'], true)) {
                $unchanged = $unchanged || ($before !== null && $after !== null && $display($before) === $display($after));
                $status = match (true) {
                    $unchanged => 'skip',
                    $before === null && $after !== null => 'added',
                    $before !== null && $after === null => 'removed',
                    default => 'changed',
                };
            }
            $reason = $field['reason'] ?? match ($status) {
                'skip' => $unchanged ? __('Unchanged') : __('Not compared'),
                'too_large' => __('Too large to show'),
                default => null,
            };
            $fieldRows[] = [
                'label' => (string) ($field['label'] ?? $field['field'] ?? ''),
                'field' => isset($field['label'], $field['field']) && $field['label'] !== $field['field'] ? (string) $field['field'] : null,
                'status' => $status,
                'before' => $status === 'too_large' ? '' : $display($before),
                'after' => $status === 'too_large' ? '' : $display($after),
                'reason' => $reason,
                'size' => $status === 'too_large' && is_scalar($field['size'] ?? null) ? (string) $field['size'] : null,
            ];
            $fieldCounts[in_array($status, ['skip', 'too_large'], true) ? 'skipped' : $status]++;
        }
    }
    $fieldSummary = collect([
        'changed' => __(':count changed', ['count' => $fieldCounts['changed']]),
        'added' => __(':count added', ['count' => $fieldCounts['added']]),
        'removed' => __(':count removed', ['count' => $fieldCounts['removed']]),
        'skipped' => __(':count skipped', ['count' => $fieldCounts['skipped']]),
    ])->filter(fn ($text, $key) => $fieldCounts[$key] > 0)->implode(', ');
    $statusLabel = [
        'added' => __('Added'),
        'removed' => __('Removed'),
        'changed' => __('Changed'),
        'skip' => __('Skipped'),
        'too_large' => __('Not shown'),
    ];

    // Normalise to a list of rows: ['type' => context|add|del, 'old' => ?int,
    // 'new' => ?int, 'text' => string]. Either accept a pre-computed `lines`
    // array or compute a line LCS diff from the two code strings. The code is
    // escaped output — it is rendered, never evaluated. A pre-computed `lines`
    // array may mark lines it left out between hunks with
    // ['type' => 'skip', 'count' => n, 'text' => optional hunk header]; that
    // becomes a `gap` row and moves both line counters on by n.
    $rows = [];
    if (is_array($lines) && $lines !== []) {
        $on = 0;
        $nn = 0;
        foreach ($lines as $row) {
            $type = $row['type'] ?? 'context';
            if ($type === 'skip') {
                $skipped = max(0, (int) ($row['count'] ?? 0));
                $on += $skipped;
                $nn += $skipped;
                $rows[] = ['type' => 'gap', 'old' => null, 'new' => null, 'count' => $skipped, 'text' => (string) ($row['text'] ?? '')];

                continue;
            }
            $type = in_array($type, ['context', 'add', 'del'], true) ? $type : 'context';
            $rows[] = [
                'type' => $type,
                'old' => $type === 'add' ? null : ++$on,
                'new' => $type === 'del' ? null : ++$nn,
                'text' => (string) ($row['text'] ?? $row['content'] ?? ''),
            ];
        }
    } else {
        $a = preg_split("/\r\n|\n|\r/", (string) $oldCode);
        $b = preg_split("/\r\n|\n|\r/", (string) $newCode);
        $n = count($a);
        $m = count($b);
        // LCS length table (suffix form), then backtrack into diff rows.
        $lcs = array_fill(0, $n + 1, array_fill(0, $m + 1, 0));
        for ($i = $n - 1; $i >= 0; $i--) {
            for ($j = $m - 1; $j >= 0; $j--) {
                $lcs[$i][$j] = $a[$i] === $b[$j]
                    ? $lcs[$i + 1][$j + 1] + 1
                    : max($lcs[$i + 1][$j], $lcs[$i][$j + 1]);
            }
        }
        $i = 0;
        $j = 0;
        while ($i < $n && $j < $m) {
            if ($a[$i] === $b[$j]) {
                $rows[] = ['type' => 'context', 'old' => $i + 1, 'new' => $j + 1, 'text' => $a[$i]];
                $i++;
                $j++;
            } elseif ($lcs[$i + 1][$j] >= $lcs[$i][$j + 1]) {
                $rows[] = ['type' => 'del', 'old' => $i + 1, 'new' => null, 'text' => $a[$i]];
                $i++;
            } else {
                $rows[] = ['type' => 'add', 'old' => null, 'new' => $j + 1, 'text' => $b[$j]];
                $j++;
            }
        }
        while ($i < $n) {
            $rows[] = ['type' => 'del', 'old' => $i + 1, 'new' => null, 'text' => $a[$i]];
            $i++;
        }
        while ($j < $m) {
            $rows[] = ['type' => 'add', 'old' => null, 'new' => $j + 1, 'text' => $b[$j]];
            $j++;
        }
    }

    $additions = count(array_filter($rows, fn ($r) => $r['type'] === 'add'));
    $deletions = count(array_filter($rows, fn ($r) => $r['type'] === 'del'));

    // The plain unified patch the copy button writes to the clipboard.
    $patch = implode("\n", array_map(function ($r) {
        if ($r['type'] === 'gap') {
            return $r['text'] !== '' ? $r['text'] : '…';
        }
        $sign = $r['type'] === 'add' ? '+' : ($r['type'] === 'del' ? '-' : ' ');
        return $sign.$r['text'];
    }, $rows));

    $rowTone = ['add' => 'bg-success/10', 'del' => 'bg-destructive/10', 'context' => ''];
    $signTone = ['add' => 'text-success', 'del' => 'text-destructive', 'context' => 'text-transparent'];
    $signChar = ['add' => '+', 'del' => '−', 'context' => ' '];
    $gutter = 'w-10 shrink-0 select-none pe-2 text-end text-xs tabular-nums text-muted-foreground';
    // Spoken in place of the decorative sign, so a change never depends on
    // colour or on a +/- glyph a screen reader may skip. select-none keeps it
    // out of a text selection copied from the body.
    $srChange = ['add' => __('Added:'), 'del' => __('Removed:'), 'context' => null];

    // Fold long unchanged runs (context prop). A run keeps `context` lines
    // next to each neighbouring change; a run at the start, the end, or next
    // to a gap row keeps none on that side. Folding fewer than two lines
    // would save nothing, so those runs stay open.
    $folds = [];
    if ($context !== null) {
        $total = count($rows);
        $at = 0;
        while ($at < $total) {
            if ($rows[$at]['type'] !== 'context') {
                $at++;

                continue;
            }
            $runStart = $at;
            while ($at < $total && $rows[$at]['type'] === 'context') {
                $at++;
            }
            $keepHead = $runStart > 0 && in_array($rows[$runStart - 1]['type'], ['add', 'del'], true) ? $context : 0;
            $keepTail = $at < $total && in_array($rows[$at]['type'], ['add', 'del'], true) ? $context : 0;
            $foldFrom = $runStart + $keepHead;
            $foldTo = $at - $keepTail;
            if ($foldTo - $foldFrom >= 2) {
                $folds[$foldFrom] = $foldTo;
            }
        }
    }

    // One flat display list: rows, gap rows, and open/close markers around
    // each fold, so both layouts render folds with the same markup.
    $items = [];
    $closeAt = null;
    foreach ($rows as $index => $r) {
        if (isset($folds[$index])) {
            $items[] = ['kind' => 'open', 'count' => $folds[$index] - $index];
            $closeAt = $folds[$index];
        }
        $items[] = $r['type'] === 'gap' ? ['kind' => 'gap', 'count' => $r['count'], 'text' => $r['text']] : ['kind' => 'row', 'row' => $r];
        if ($closeAt === $index + 1) {
            $items[] = ['kind' => 'close'];
            $closeAt = null;
        }
    }

    // Split view: align each contiguous change block so deletions sit on the
    // left and additions on the right; context lines span both sides.
    $pairs = [];
    if ($layout === 'split') {
        $buf = ['del' => [], 'add' => []];
        $flush = function () use (&$pairs, &$buf) {
            $k = max(count($buf['del']), count($buf['add']));
            for ($x = 0; $x < $k; $x++) {
                $pairs[] = ['kind' => 'pair', 'left' => $buf['del'][$x] ?? null, 'right' => $buf['add'][$x] ?? null];
            }
            $buf = ['del' => [], 'add' => []];
        };
        foreach ($items as $item) {
            if ($item['kind'] !== 'row') {
                $flush();
                $pairs[] = $item;

                continue;
            }
            $r = $item['row'];
            if ($r['type'] === 'context') {
                $flush();
                $pairs[] = ['kind' => 'pair', 'left' => $r, 'right' => $r];
            } elseif ($r['type'] === 'del') {
                $buf['del'][] = $r;
            } else {
                $buf['add'][] = $r;
            }
        }
        $flush();
    }
    $entries = $layout === 'split' ? $pairs : $items;
@endphp

@if ($mode === 'fields')
<div
    data-slot="diff-viewer"
    data-mode="fields"
    {{ $attributes->merge(['class' => 'overflow-hidden rounded-lg border border-border bg-card text-foreground']) }}
>
    <div class="flex items-center justify-between gap-2 border-b border-border px-3 py-2 text-xs">
        <div class="flex min-w-0 items-center gap-2">
            @if ($oldTitle !== null || $newTitle !== null)
                <code class="truncate font-mono font-medium text-foreground">{{ $oldTitle ?? $newTitle }}</code>
                @if ($oldTitle !== null && $newTitle !== null && $oldTitle !== $newTitle)
                    <svg class="size-3 shrink-0 text-muted-foreground rtl:-scale-x-100" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                        <path d="M5 12h14M13 6l6 6-6 6" />
                    </svg>
                    <code class="truncate font-mono font-medium text-foreground">{{ $newTitle }}</code>
                @endif
            @endif
        </div>
        <span data-slot="diff-viewer-summary" class="shrink-0 font-medium tabular-nums text-muted-foreground">{{ $fieldSummary !== '' ? $fieldSummary : __('No changes') }}</span>
    </div>

    <div aria-hidden="true" class="hidden grid-cols-5 gap-4 border-b border-border px-3 py-2 text-xs font-medium text-muted-foreground sm:grid">
        <span>{{ __('Field') }}</span>
        <span class="col-span-2">{{ __('Before') }}</span>
        <span class="col-span-2">{{ __('After') }}</span>
    </div>

    <ul role="list" class="divide-y divide-border text-sm">
        @foreach ($fieldRows as $row)
            <li data-slot="diff-viewer-field" data-status="{{ $row['status'] }}" class="grid grid-cols-1 gap-2 px-3 py-2 sm:grid-cols-5 sm:gap-4">
                <div class="min-w-0">
                    <span @class(['wrap-anywhere font-medium', 'text-foreground' => ! in_array($row['status'], ['skip', 'too_large'], true), 'text-muted-foreground' => in_array($row['status'], ['skip', 'too_large'], true)])>{{ $row['label'] }}</span>
                    <span class="sr-only">, {{ $statusLabel[$row['status']] }}</span>
                    @if ($row['field'] !== null)
                        <code class="block truncate font-mono text-xs text-muted-foreground">{{ $row['field'] }}</code>
                    @endif
                </div>
                @if (in_array($row['status'], ['skip', 'too_large'], true))
                    <div data-slot="diff-viewer-reason" class="min-w-0 text-muted-foreground sm:col-span-4">
                        <span>{{ $row['reason'] }}@if ($row['size'] !== null) ({{ $row['size'] }})@endif</span>
                        @if ($row['status'] === 'skip' && ($row['before'] !== '' || $row['after'] !== ''))
                            <code dir="auto" class="mt-1 block whitespace-pre-wrap wrap-anywhere font-mono text-xs">{{ $row['before'] !== '' ? $row['before'] : $row['after'] }}</code>
                        @endif
                    </div>
                @else
                    @foreach (['before' => $row['before'], 'after' => $row['after']] as $side => $value)
                        <div
                            data-slot="diff-viewer-{{ $side }}"
                            @class([
                                'min-w-0 rounded-sm px-2 py-1 sm:col-span-2',
                                'bg-muted/30' => $value === '',
                                'bg-destructive/10' => $value !== '' && $side === 'before',
                                'bg-success/10' => $value !== '' && $side === 'after',
                            ])
                        >
                            <span class="block text-xs text-muted-foreground sm:sr-only">{{ $side === 'before' ? __('Before') : __('After') }}</span>
                            @if ($value === '')
                                <span class="text-muted-foreground" aria-hidden="true">—</span>
                                <span class="sr-only">{{ __('Empty') }}</span>
                            @else
                                <span class="flex min-w-0 gap-2">
                                    <span @class(['select-none', 'text-destructive' => $side === 'before', 'text-success' => $side === 'after']) aria-hidden="true">{{ $side === 'before' ? '−' : '+' }}</span>
                                    <code dir="auto" class="min-w-0 flex-1 whitespace-pre-wrap wrap-anywhere font-mono">{{ $value }}</code>
                                </span>
                            @endif
                        </div>
                    @endforeach
                @endif
            </li>
        @endforeach
    </ul>
</div>
@else
<div
    data-slot="diff-viewer"
    data-layout="{{ $layout }}"
    {{ $attributes->merge(['class' => 'overflow-hidden rounded-lg border border-border bg-card text-foreground']) }}
>
    {{-- Header: filename transformation (or language pill) + change summary + copy --}}
    <div class="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
        <div class="flex min-w-0 items-center gap-2 text-xs">
            @if ($oldTitle !== null || $newTitle !== null)
                <code class="truncate font-mono font-medium text-foreground">{{ $oldTitle ?? $newTitle }}</code>
                @if ($oldTitle !== null && $newTitle !== null && $oldTitle !== $newTitle)
                    <svg class="size-3 shrink-0 text-muted-foreground rtl:-scale-x-100" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                        <path d="M5 12h14M13 6l6 6-6 6" />
                    </svg>
                    <code class="truncate font-mono font-medium text-foreground">{{ $newTitle }}</code>
                @endif
            @elseif ($language !== null)
                <span class="inline-flex items-center rounded-sm bg-muted px-2 py-0.5 font-medium text-muted-foreground">{{ $language }}</span>
            @endif
        </div>

        <div class="flex shrink-0 items-center gap-2">
            <span dir="ltr" class="inline-flex items-center gap-2 font-mono text-xs font-medium tabular-nums">
                <span class="text-success" aria-hidden="true">+{{ $additions }}</span>
                <span class="text-destructive" aria-hidden="true">−{{ $deletions }}</span>
                <span class="sr-only">{{ __(':add additions, :del deletions', ['add' => $additions, 'del' => $deletions]) }}</span>
            </span>
            <x-ui.copy-button
                :value="$patch"
                :label="__('Copy diff')"
                :copied-label="__('Copied')"
                class="size-7 h-7 w-7 border-transparent bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground"
            />
        </div>
    </div>

    {{-- Body: code reads left to right in every locale, so the gutters, signs
         and scroll start stay on the left on an RTL page too. --}}
    <div dir="ltr" class="overflow-x-auto font-mono text-sm leading-6">
        <div class="min-w-max">
            @foreach ($entries as $entry)
                @if ($entry['kind'] === 'open')
                    <details data-slot="diff-viewer-fold" class="group/fold">
                        <summary class="flex cursor-pointer list-none items-center gap-2 bg-muted/40 px-3 py-1 text-xs leading-6 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-inset focus-visible:ring-ring [&::-webkit-details-marker]:hidden">
                            <svg class="size-3 shrink-0 group-open/fold:rotate-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
                            <span class="group-open/fold:hidden">{{ trans_choice(':count unchanged line|:count unchanged lines', $entry['count'], ['count' => $entry['count']]) }}</span>
                            <span class="hidden group-open/fold:inline">{{ trans_choice('Hide :count unchanged line|Hide :count unchanged lines', $entry['count'], ['count' => $entry['count']]) }}</span>
                        </summary>
                @elseif ($entry['kind'] === 'close')
                    </details>
                @elseif ($entry['kind'] === 'gap')
                    <div data-slot="diff-viewer-gap" class="flex items-center gap-2 bg-muted/40 px-3 py-1 text-xs leading-6 text-muted-foreground">
                        <svg class="size-3 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v6m0 6v6M8 7l4 4 4-4M8 17l4-4 4 4" /></svg>
                        <span>{{ $entry['count'] > 0 ? trans_choice(':count unchanged line|:count unchanged lines', $entry['count'], ['count' => $entry['count']]) : __('Lines not shown') }}</span>
                        @if ($entry['text'] !== '')
                            <code class="min-w-0 truncate">{{ $entry['text'] }}</code>
                        @endif
                    </div>
                @elseif ($layout === 'split')
                    <div class="flex">
                        {{-- Left (original) --}}
                        <div class="flex w-1/2 min-w-0 flex-1 {{ $entry['left'] ? $rowTone[$entry['left']['type']] : 'bg-muted/30' }}" @if (! $entry['left']) aria-hidden="true" @endif>
                            <span class="{{ $gutter }}" aria-hidden="true">{{ $entry['left']['old'] ?? '' }}</span>
                            <span class="w-4 shrink-0 select-none text-center {{ $entry['left'] ? $signTone[$entry['left']['type']] : 'text-transparent' }}" aria-hidden="true">{{ $entry['left'] ? $signChar[$entry['left']['type']] : '' }}</span>
                            <code dir="ltr" class="flex-1 whitespace-pre pe-3 text-start">@if ($entry['left'] && $srChange[$entry['left']['type']])<span class="sr-only select-none">{{ $srChange[$entry['left']['type']] }} </span>@endif{{ $entry['left']['text'] ?? '' }}</code>
                        </div>
                        {{-- Right (modified). An unchanged line is read once, from the left. --}}
                        <div class="flex w-1/2 min-w-0 flex-1 border-s border-border {{ $entry['right'] ? $rowTone[$entry['right']['type']] : 'bg-muted/30' }}" @if (! $entry['right'] || $entry['right']['type'] === 'context') aria-hidden="true" @endif>
                            <span class="{{ $gutter }}" aria-hidden="true">{{ $entry['right']['new'] ?? '' }}</span>
                            <span class="w-4 shrink-0 select-none text-center {{ $entry['right'] ? $signTone[$entry['right']['type']] : 'text-transparent' }}" aria-hidden="true">{{ $entry['right'] ? $signChar[$entry['right']['type']] : '' }}</span>
                            <code dir="ltr" class="flex-1 whitespace-pre pe-3 text-start">@if ($entry['right'] && $srChange[$entry['right']['type']])<span class="sr-only select-none">{{ $srChange[$entry['right']['type']] }} </span>@endif{{ $entry['right']['text'] ?? '' }}</code>
                        </div>
                    </div>
                @else
                    @php($r = $entry['row'])
                    <div class="flex {{ $rowTone[$r['type']] }}">
                        <span class="{{ $gutter }}" aria-hidden="true">{{ $r['old'] ?? '' }}</span>
                        <span class="{{ $gutter }}" aria-hidden="true">{{ $r['new'] ?? '' }}</span>
                        <span class="w-4 shrink-0 select-none text-center {{ $signTone[$r['type']] }}" aria-hidden="true">{{ $signChar[$r['type']] }}</span>
                        <code dir="ltr" class="flex-1 whitespace-pre pe-3 text-start">@if ($srChange[$r['type']])<span class="sr-only select-none">{{ $srChange[$r['type']] }} </span>@endif{{ $r['text'] }}</code>
                    </div>
                @endif
            @endforeach
        </div>
    </div>
</div>
@endif

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.2.0
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