Markdown
Renders untrusted Markdown to safe HTML on the server (CommonMark plus GitHub tables, strikethrough, task lists and autolinks) with raw HTML escaped and unsafe links removed, styled by the rich prose variant.
Preview
Release notes
Markdown from users renders safely on the server, with emphasis, removed text and inline code. Read the install guide or visit https://example.com.
What changed
- Lists keep their markers
- and nest a second level
- and a third
- and nest a second level
- Done: Task lists show their state
- Not done: Open tasks stay unchecked
- Ordered steps count up
- Raw HTML such as <script>alert(1)</script> is shown as text
Quotes sit on a start-side rule that mirrors in right-to-left text.
$html = Str::markdown($comment->body, ['html_input' => 'escape']);
| Element | Behaviour |
|---|---|
| Code block | Scrolls inside itself |
| Table | Scrolls inside its wrapper |

@php
$source = <<<'MARKDOWN'
# Release notes
Markdown from users renders **safely** on the server, with _emphasis_, ~~removed text~~ and `inline code`. Read the [install guide](https://example.com/docs/markdown) or visit https://example.com.
## What changed
- Lists keep their markers
- and nest a second level
- and a third
- [x] Task lists show their state
- [ ] Open tasks stay unchecked
1. Ordered steps count up
2. Raw HTML such as <script>alert(1)</script> is shown as text
> Quotes sit on a start-side rule that mirrors in right-to-left text.
```php
$html = Str::markdown($comment->body, ['html_input' => 'escape']);
```
---
| Element | Behaviour |
| --- | ---: |
| Code block | Scrolls inside itself |
| Table | Scrolls inside its wrapper |

MARKDOWN;
@endphp
<x-ui.markdown :source="$source" external-links="nofollow" />
Installation
php artisan ui:add markdown
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: Markdown (`markdown`)
Renders untrusted Markdown to safe HTML on the server (CommonMark plus GitHub tables, strikethrough, task lists and autolinks) with raw HTML escaped and unsafe links removed, styled by the rich prose variant.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add markdown
```
## Usage
```blade
@php
$source = <<<'MARKDOWN'
# Release notes
Markdown from users renders **safely** on the server, with _emphasis_, ~~removed text~~ and `inline code`. Read the [install guide](https://example.com/docs/markdown) or visit https://example.com.
## What changed
- Lists keep their markers
- and nest a second level
- and a third
- [x] Task lists show their state
- [ ] Open tasks stay unchecked
1. Ordered steps count up
2. Raw HTML such as <script>alert(1)</script> is shown as text
> Quotes sit on a start-side rule that mirrors in right-to-left text.
```php
$html = Str::markdown($comment->body, ['html_input' => 'escape']);
```
---
| Element | Behaviour |
| --- | ---: |
| Code block | Scrolls inside itself |
| Table | Scrolls inside its wrapper |

MARKDOWN;
@endphp
<x-ui.markdown :source="$source" external-links="nofollow" />
```
## Props
- `source` (string, default ``) — The Markdown text to render. Pass untrusted text here; raw HTML in it is escaped and unsafe link schemes are removed. A non-string value renders nothing.
- `size` (md|sm|lg, default `md`) — Forwarded to prose: reading measure and base text size.
- `as` (string, default `div`) — Forwarded to prose: the tag for the text block (article, div, section or main). Defaults to div because Markdown usually sits inside a card or a column.
- `centered` (bool, default `false`) — Forwarded to prose: centers the bounded-width block. Off by default so the text aligns to the start edge of its container.
- `externalLinks` (none|nofollow|new-tab, default `none`) — Set as external-links. none leaves links as written; nofollow adds rel="nofollow noopener noreferrer" to links whose host differs from the app URL or the current request host; new-tab also adds target="_blank". Relative links are always internal.
- `headingOffset` (int, default `0`) — Set as heading-offset. Moves every heading down this many levels (0 to 5, capped at h6), so # renders as h2 with an offset of 1 when the page already has an h1.
- `sourceToggle` (bool, default `false`) — Set as source-toggle. Adds a Formatted / Source tab pair and a Copy Markdown button above the text; Source shows the raw Markdown escaped, in monospace, wrapped. Off by default, which renders exactly as before.
## Use when
- Use when users need rich structured authoring, annotation, or collaborative editing controls.
- Displaying Markdown written by users or stored in the database, such as comments, notes, descriptions or imported documents, as formatted read-only text.
- Needing a server-only renderer that escapes raw HTML and drops javascript:, vbscript: and data: links without adding JavaScript or a composer package.
## Avoid when
- Do not use rich editor affordances for simple one-line or plain-text tasks.
- Users need to write or edit formatted text; use editor, which is an input surface.
- The content is already trusted HTML (for example saved editor output); wrap it in prose with variant="rich" instead of converting it again.
- The content needs raw HTML embeds, iframes or custom components; this renderer escapes all raw HTML on purpose.
## Anti-patterns
- Rich editors for one-line or easily validated input
## Rules
- Use the `<brok:markdown>` tag (or `<x-ui.markdown>`) 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/markdown
- Registry JSON (files, props, contract): https://brokui.dev/r/open/markdown.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Long Content
@php
$source = <<<'MARKDOWN'
## A heading with averyveryverylongunbrokenwordthatmustwrapinsteadofwideningthepage
A paragraph with a long URL: https://example.com/a/very/long/unbroken/localized/path/that/must/wrap/without/forcing/the/page/to/scroll/horizontally?with=query&and=more
```text
A code block line that is much wider than the column and scrolls inside its own box instead of the page: 0123456789abcdefghijklmnopqrstuvwxyz0123456789
```
| Column one | Column two | Column three | Column four | Column five |
| --- | --- | --- | --- | --- |
| A cell with several words | https://example.com/another/long/unbroken/url/in/a/table/cell | More text | More text | More text |
MARKDOWN;
@endphp
<div class="max-w-xs">
<x-ui.markdown :source="$source" />
</div>
@php
$source = <<<'MARKDOWN'
## ملاحظات الإصدار
يعرض هذا المكون نص **Markdown** بأمان على الخادم. اقرأ [دليل التثبيت](https://example.com/docs).
- القوائم تحافظ على علاماتها
- وتتداخل في مستوى ثانٍ
> تظهر الاقتباسات على خط في جهة البداية.
| العنصر | السلوك |
| --- | --- |
| الجدول | يلتف داخل الغلاف |
MARKDOWN;
@endphp
<div dir="rtl" lang="ar">
<x-ui.markdown :source="$source" />
</div>
Source Toggle
@php
$source = <<<'MARKDOWN'
## Deploy checklist
Run the **migrations** before the new code takes traffic, then warm the cache with `php artisan optimize`.
- [x] Back up the database
- [ ] Switch the queue workers
- Read the [runbook](https://example.com/runbook) if a step fails
| Step | Owner |
| --- | --- |
| Migrate | Platform |
| Smoke test | QA |
MARKDOWN;
@endphp
{{-- source-toggle: Formatted shows the rendered text, Source the raw Markdown it came from. --}}
<x-ui.markdown :source="$source" source-toggle class="w-full" />
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| source | string | The Markdown text to render. Pass untrusted text here; raw HTML in it is escaped and unsafe link schemes are removed. A non-string value renders nothing. | |
| size | md | sm | lg | md | Forwarded to prose: reading measure and base text size. |
| as | string | div | Forwarded to prose: the tag for the text block (article, div, section or main). Defaults to div because Markdown usually sits inside a card or a column. |
| centered | bool | false | Forwarded to prose: centers the bounded-width block. Off by default so the text aligns to the start edge of its container. |
| externalLinks | none | nofollow | new-tab | none | Set as external-links. none leaves links as written; nofollow adds rel="nofollow noopener noreferrer" to links whose host differs from the app URL or the current request host; new-tab also adds target="_blank". Relative links are always internal. |
| headingOffset | int | 0 | Set as heading-offset. Moves every heading down this many levels (0 to 5, capped at h6), so # renders as h2 with an offset of 1 when the page already has an h1. |
| sourceToggle | bool | false | Set as source-toggle. Adds a Formatted / Source tab pair and a Copy Markdown button above the text; Source shows the raw Markdown escaped, in monospace, wrapped. Off by default, which renders exactly as before. |
Slots
Default Blade slot only.
Renders as article, div, section, main.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Converts the source with Laravel's Str::markdown (league/commonmark, which Laravel already requires) using html_input=escape, allow_unsafe_links=false and max_nesting_level=20; GitHub-flavoured tables, strikethrough, task lists and autolinks are on.
- Content nested deeper than 20 levels is rendered as plain text, so a hostile document cannot exhaust the stack.
- Code blocks and tables get tabindex="0" and scroll horizontally inside themselves; long words and URLs in paragraphs wrap, so the page never scrolls sideways.
- Renders on the server only; it ships no JavaScript unless source-toggle is set.
- With source-toggle, the tabs item switches between the rendered view and the raw source. Both views share one grid cell and the hidden one is only made invisible, so the block keeps the height of the taller view and nothing below it moves on a switch. Without JavaScript the rendered view shows and the inert tab row stays invisible.
- Task-list checkboxes are read-only: each input is aria-hidden and a screen-reader-only 'Done:' or 'Not done:' before the item text states its state.
- Declares registry capability flags: a11y, authoredStateFixtures, responsive, rtl, darkMode, localized.
Guidance
Create or revise structured rich content.
Use when
- Use when users need rich structured authoring, annotation, or collaborative editing controls.
- Displaying Markdown written by users or stored in the database, such as comments, notes, descriptions or imported documents, as formatted read-only text.
- Needing a server-only renderer that escapes raw HTML and drops javascript:, vbscript: and data: links without adding JavaScript or a composer package.
Avoid when
- Do not use rich editor affordances for simple one-line or plain-text tasks.
- Users need to write or edit formatted text; use editor, which is an input surface.
- The content is already trusted HTML (for example saved editor output); wrap it in prose with variant="rich" instead of converting it again.
- The content needs raw HTML embeds, iframes or custom components; this renderer escapes all raw HTML on purpose.
Use instead
- Textarea or plain field for simple content
Anti-patterns
- Rich editors for one-line or easily validated input
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
managed
- Keeps the document's semantic elements (headings, lists, tables with th, blockquote, code) so assistive technology reads the structure; heading-offset keeps the outline under the page heading.
- Scrollable code blocks and tables are keyboard focusable with the global focus ring, so keyboard users can scroll them.
- Images keep the alt text written in the Markdown; authors must supply it.
- The source toggle is a tablist named Markdown view with tabs semantics from the tabs item: arrow keys move between Formatted and Source, the active tab is aria-selected and each panel is labelled by its tab and keyboard focusable. The hidden panel is visibility hidden, so it is out of the accessibility tree and the tab order.
- The new-tab link mode opens external links in a new tab without a spoken warning; prefer nofollow unless the product needs new tabs.
- 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="markdown-{{ $record->id }}">
@php
$source = <<<'MARKDOWN'
# Release notes
Markdown from users renders **safely** on the server, with _emphasis_, ~~removed text~~ and `inline code`. Read the [install guide](https://example.com/docs/markdown) or visit https://example.com.
## What changed
- Lists keep their markers
- and nest a second level
- and a third
- [x] Task lists show their state
- [ ] Open tasks stay unchecked
1. Ordered steps count up
2. Raw HTML such as <script>alert(1)</script> is shown as text
> Quotes sit on a start-side rule that mirrors in right-to-left text.
```php
$html = Str::markdown($comment->body, ['html_input' => 'escape']);
```
---
| Element | Behaviour |
| --- | ---: |
| Code block | Scrolls inside itself |
| Table | Scrolls inside its wrapper |

MARKDOWN;
@endphp
<x-ui.markdown :source="$source" external-links="nofollow" />
</div>
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'source' => '',
'size' => 'md',
'as' => 'div',
'centered' => false,
'externalLinks' => 'none',
'headingOffset' => 0,
// Opt-in: a Formatted / Source tab pair above the text. The rendered view
// is the server default, so the text still reads with no JavaScript.
'sourceToggle' => false,
])
@php
$allowedTags = ['article', 'div', 'section', 'main'];
$tag = in_array($as, $allowedTags, true) ? $as : 'div';
$markdownSource = is_string($source) || $source instanceof Stringable ? (string) $source : '';
$linkModes = ['none', 'nofollow', 'new-tab'];
$linkMode = in_array($externalLinks, $linkModes, true) ? $externalLinks : 'none';
$offset = max(0, min(5, (int) $headingOffset));
$sourceToggle = filter_var($sourceToggle, FILTER_VALIDATE_BOOLEAN);
$options = [
'html_input' => 'escape',
'allow_unsafe_links' => false,
'max_nesting_level' => 20,
];
$extensions = [];
if ($linkMode !== 'none') {
$internalHosts = array_values(array_unique(array_filter([
parse_url((string) config('app.url'), PHP_URL_HOST),
request()->getHost(),
], static fn (mixed $host): bool => is_string($host) && $host !== '')));
$options['external_link'] = [
'internal_hosts' => $internalHosts,
'open_in_new_window' => $linkMode === 'new-tab',
'nofollow' => 'external',
'noopener' => 'external',
'noreferrer' => 'external',
];
$extensions[] = new \League\CommonMark\Extension\ExternalLink\ExternalLinkExtension;
}
$html = \Illuminate\Support\Str::markdown($markdownSource, $options, $extensions);
if ($offset > 0) {
$html = (string) preg_replace_callback(
'/<(\/?)h([1-6])(?=[\s>])/',
static fn (array $match): string => '<'.$match[1].'h'.min(6, (int) $match[2] + $offset),
$html,
);
}
// Task-list checkboxes are read-only state, not form controls: hide the
// input from assistive technology and say its state in text instead.
$html = str_replace(
['<input checked="" disabled="" type="checkbox">', '<input disabled="" type="checkbox">'],
[
'<input checked="" disabled="" type="checkbox" aria-hidden="true"><span class="sr-only">'.e(__('Done:')).' </span>',
'<input disabled="" type="checkbox" aria-hidden="true"><span class="sr-only">'.e(__('Not done:')).' </span>',
],
$html,
);
$html = str_replace(
['<pre>', '<table>', '</table>'],
['<pre tabindex="0" dir="ltr">', '<div data-slot="prose-table-wrapper" tabindex="0"><table>', '</table></div>'],
$html,
);
@endphp
@if ($sourceToggle)
{{--
Source toggle: the tabs item owns the keyboard model (roving focus, arrow
keys, aria-selected). Both panels share one grid cell and the hidden one
is only made invisible, so the block keeps the height of the taller view
and switching never moves the content below it. Before Alpine boots (or
without JavaScript) the rendered panel shows and the inert tab row stays
invisible.
--}}
<div data-slot="markdown" data-source-toggle {{ $attributes->merge(['class' => 'min-w-0']) }}>
<x-ui.tabs default="rendered" class="!gap-2">
<div data-slot="markdown-toolbar" class="invisible flex min-w-0 items-center justify-between gap-2" x-bind:class="{ 'invisible': false }">
<x-ui.tabs.list :aria-label="__('Markdown view')">
<x-ui.tabs.trigger value="rendered">{{ __('Formatted') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="source">{{ __('Source') }}</x-ui.tabs.trigger>
</x-ui.tabs.list>
<x-ui.copy-button
:value="$markdownSource"
:label="__('Copy Markdown')"
:copied-label="__('Copied')"
/>
</div>
<div class="grid min-w-0 grid-cols-1 *:col-start-1 *:row-start-1">
<div
data-slot="markdown-rendered"
role="tabpanel"
tabindex="0"
class="min-w-0 rounded-sm focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background"
x-bind:id="$id('tabs-panel', 'rendered')"
x-bind:aria-labelledby="$id('tabs-trigger', 'rendered')"
x-bind:class="{ 'invisible': ! isActive('rendered') }"
>
<x-ui.prose variant="rich" :size="$size" :as="$tag" :centered="$centered">
{{-- Same trusted renderer output as below; see the note there. --}}
{!! $html !!}
</x-ui.prose>
</div>
<div
data-slot="markdown-source"
role="tabpanel"
tabindex="0"
class="invisible min-w-0 rounded-md bg-muted/40 p-4 focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background"
x-bind:id="$id('tabs-panel', 'source')"
x-bind:aria-labelledby="$id('tabs-trigger', 'source')"
x-bind:class="{ 'invisible': ! isActive('source') }"
>
<pre dir="auto" class="m-0 whitespace-pre-wrap wrap-anywhere font-mono text-sm leading-6 text-foreground"><code>{{ $markdownSource }}</code></pre>
</div>
</div>
</x-ui.tabs>
</div>
@else
<div data-slot="markdown" {{ $attributes->merge(['class' => 'min-w-0']) }}>
<x-ui.prose variant="rich" :size="$size" :as="$tag" :centered="$centered">
{{--
Unescaped output is safe here and only here: $html is produced by the
CommonMark renderer from the source with html_input=escape (raw HTML
becomes text), allow_unsafe_links=false (javascript:, vbscript:,
file: and non-image data: URLs are dropped) and a bounded nesting
depth. The post-processing above only rewrites tags the renderer
itself emitted; escaped input can never contain a literal "<".
--}}
{!! $html !!}
</x-ui.prose>
</div>
@endif
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.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