Code Display
Read-only code surfaces — a highlighted code block, a single-line snippet, a tabbed CLI command block and a unified/split diff.
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
Removed: export function counter(start) {
Added: export function counter(start = 0) {
let count = start;
Removed: return () => count++;
Added: const next = () => ++count;
Added: return next;
}
@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
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.
-
resources/views/components/ui/diff-viewer.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: 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
{{-- 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'],
]"
/>
@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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-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
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.
@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