Skip to content

Rich Text

The RichText field renders a WYSIWYG editor with a configurable toolbar. It submits an HTML string, or null when the editor is empty. The PHP field adds a string rule automatically. Add nullable() to optional editors.

Basic Usage

Example
php
use InertiaUI\Forms\Fields\RichText;

RichText::make('body')->nullable();

This renders an editor with the default toolbar: bold, italic, underline, strike, an H2 heading, lists, links, blockquotes, inline code, undo, and redo.

Toolbar Presets

Simple

A minimal toolbar with just bold, italic, and link:

Example
php
RichText::make('bio')->simple();

Full

The full preset adds the larger editor controls, including image embeds, task lists, and text alignment:

php
RichText::make('content')->full();

Custom

Pass your own toolbar configuration:

Example
php
RichText::make('notes')
    ->toolbar('bold italic | bullet ordered ~ link undo redo');

You may also pass an array. Each nested array becomes a visual button group:

php
RichText::make('notes')->toolbar([
    ['bold', 'italic'],
    ['bulletList', 'orderedList'],
    ['link'],
]);

Flat arrays are accepted too:

php
RichText::make('notes')->toolbar([
    'bold', 'italic', '|', 'bulletList', 'orderedList', '|', 'link',
]);

Use | as a visual separator between button groups and ~ as a flexible spacer. The string grammar also accepts these aliases: bullet for bulletList, ordered for orderedList, quote for blockquote, hr for horizontalRule, and h1, h2, or h3 for heading.

RichText::normalizeToolbar() exposes the same normalization when application code needs the canonical flat toolbar array without building a field instance.

h1, h2, and h3 all normalize to the single heading toolbar control. That control toggles an H2 heading; it is not a heading-level picker.

Available Toolbar Items

ItemDescription
boldBold text
italicItalic text
underlineUnderlined text
strikeStrikethrough text
headingToggle an H2 heading
highlightHighlighted text
subscriptSubscript text
superscriptSuperscript text
bulletListUnordered list
orderedListNumbered list
taskListCheckbox list
linkHyperlink
imageImage embed
blockquoteBlock quote
codeInline code
codeBlockCode block
horizontalRuleHorizontal divider
alignLeftLeft alignment
alignCenterCenter alignment
alignRightRight alignment
undoUndo
redoRedo

The link toolbar item opens a native modal dialog for inserting, editing, and removing links. Configure the generated link policy from PHP:

php
RichText::make('body')
    ->simple()
    ->linkTargetBlank()
    ->linkNoopener()
    ->linkNoreferrer()
    ->linkAllowedProtocols(['https', 'mailto'])
    ->linkDefaultProtocol('https');

By default, links open in the same tab, noopener is enabled for links that open in a new tab, noreferrer is disabled, auto-linking and link-on-paste are enabled, the default protocol is http, and allowed protocols are http, https, mailto, and tel.

Use autoLink(false) to stop converting typed URLs and linkOnPaste(false) to stop converting a pasted URL. These switches do not disable the link dialog. linkAllowedProtocols() normalizes and deduplicates protocol names. Removing the current default protocol makes the first allowed protocol the new default. linkDefaultProtocol() must select a protocol that is already allowed.

Use allowLinkTargetOverride(false) when the field configuration should fully control whether links open in a new tab. With target override allowed, authors may toggle "Open in new tab" per link.

php
RichText::make('body')
    ->linkTargetBlank()
    ->allowLinkTargetOverride(false);

Use linkRel() for additional rel tokens such as nofollow, sponsored, or ugc:

php
RichText::make('body')
    ->linkRel(['nofollow', 'ugc']);

Protocol options are validated when the form is built. Unsafe or malformed protocols such as javascript: and data: are rejected, and the editor also rejects links whose protocol is not allowed. This policy applies to links created or normalized by the editor; it is not a replacement for server-side HTML sanitization when accepting untrusted HTML.

Height

Set minimum and/or maximum heights for the editor area in pixels:

php
RichText::make('content')
    ->minHeight(200)
    ->maxHeight(600);

Image Embeds

The image toolbar item accepts image URLs by default. Add imageUploads() when authors should also be able to pick an image file from their device:

php
use InertiaUI\Forms\Fields\RichText;
use InertiaUI\Forms\FileUploads\UploadConfig;

RichText::make('body')
    ->full()
    ->imageUploads(fn (UploadConfig $uploads) => $uploads
        ->maxSize(2048)
        ->maxDimensions(width: 2400, height: 1600));

Upload sizes are expressed in kilobytes. Image dimensions are expressed in pixels.

RichText image uploads use the same temporary upload pipeline as the File field. Custom routes, chunking, and direct-to-storage uploads are covered in Choosing an Upload Path. Upload validators and token validation are covered in Validation.

Image helpers such as accept(), maxSize(), minSize(), dimensions(), minDimensions(), and maxDimensions() run during the upload request. The configuration always includes Laravel's image rule. accept() may narrow the allowed image MIME types, but it does not turn this into a general file upload.

The generated companion field is <name>_images, for example body_images. Use getImageUploadFieldName() on the field or RichTextUploads::imageUploadFieldName(field: 'body') when application code needs that name explicitly. Both return body_images for the body field.

storeWithForm() is not supported for RichText images. RichText still submits an HTML string for the field itself. Uploaded images are tracked in a companion token list, so your controller may resolve the temporary uploads and rewrite the HTML before storing it.

Image Resizing

Images inserted by URL or uploaded through imageUploads() may be resized in the editor with corner handles. The editor stores the user's resized display size as numeric width and height attributes on the <img> tag, so the submitted value stays regular HTML. RichTextUploads and RichTextContent preserve those attributes when they rewrite temporary upload markers or stored-image references.

Storage and rendering callbacks receive the existing image attributes through the RichTextImage builder. Use RichTextImage::width(), height(), or dimensions() when your application wants to normalize or override the user's resized display size while storing or rendering an image. These fluent setters write the rendered <img width height> attributes. They are not getters and do not describe the source image's intrinsic pixel dimensions. Use upload validation helpers such as UploadConfig::dimensions() and maxDimensions() when you need to validate the actual uploaded file.

php
$image->dimensions(width: 720, height: 480);

Leave the dimensions unchanged to preserve the size chosen in the editor.

Storing Uploaded Images

Use the validated Form in your controller or form request handler. For public files with stable URLs, store each image on a Laravel filesystem disk and write that URL into the HTML:

php
use Illuminate\Support\Facades\Storage;
use InertiaUI\Forms\FileUploads\SubmittedUpload;
use InertiaUI\Forms\RichText\RichTextImage;
use RuntimeException;

$storedHtml = $form->richText('body')
    ->storeImagesUsing(function (SubmittedUpload $upload, RichTextImage $image): RichTextImage {
        $path = $upload->store(
            path: 'content-images',
            options: ['disk' => 'public'],
            deleteTemporary: false,
        );

        if ($path === false) {
            throw new RuntimeException('The image could not be stored.');
        }

        return $image->src(Storage::disk('public')->url($path));
    })
    ->toHtml();

SubmittedUpload::store() mirrors Laravel's uploaded-file API and returns the stored path, or false when storage fails. path is the directory on the target disk, while options['disk'] selects Laravel's public filesystem disk. Setting deleteTemporary: false keeps the staged source available until RichTextUploads has stored every image and successfully rewritten the HTML; the helper then deletes it. Storage::disk('public')->url($path) turns the stored path into its public URL. Laravel's local public disk also requires the usual php artisan storage:link setup before browsers may reach that URL. Direct-to-storage uploads must stay on their source disk, so use that configured disk instead of public for that workflow.

The richText() accessor applies the matching field's imageUploads() policy and verifies the HTML markers against the companion tokens.

RichTextImage may set src(), alt(), title(), width(), height(), or both dimensions together. attribute() and attributes() set other valid image attributes; pass null or false to remove one. The callback may mutate the supplied builder or return another RichTextImage. Returning null keeps the supplied builder.

Image Upload Validation

The Form accessor validates each image against the matching field's MIME type, size, and dimension policy before storage. It rejects fields that are unavailable or do not enable image uploads.

Direct processing outside a Form requires an explicit policy:

php
use InertiaUI\Forms\FileUploads\UploadConfig;
use InertiaUI\Forms\RichText\RichTextUploads;

$storedHtml = RichTextUploads::from(
    request: $request,
    field: 'body',
)
    ->validateUsing(UploadConfig::images()
        ->maxSize(2048)
        ->maxDimensions(width: 2400, height: 1600))
    ->storeImagesInMediaLibrary(
        model: $post,
        collection: 'content-images',
    )
    ->toHtml();

request is the submitted request and field is the RichText field name. You may also use forField($field) to apply a RichText field's policy in a non-Form workflow. RichTextUploads::from() does not discover a policy from the ambient request.

Invalid MIME types, sizes, and dimensions throw a ValidationException before storage.

withoutImageValidation() accepts any file behind a valid upload token. Use it only when your own pipeline validates the files.

Spatie Media Library

Declare the Media Library destination on the field, then let Form::save() store uploaded images and persist the rewritten HTML:

php
RichText::make('body')
    ->imageUploads(fn (UploadConfig $uploads) => $uploads
        ->maxSize(2048)
        ->maxDimensions(width: 2400, height: 1600))
    ->storeImagesInMediaLibrary(
        collection: 'content-images',
        deleteUnused: true,
    );
php
use App\Forms\PostForm;
use InertiaUI\Forms\Validate;

public function update(#[Validate] PostForm $form, Post $post)
{
    $form->save($post);

    return back();
}

The model must implement Spatie Media Library's HasMedia contract. The storage declaration stays on the server and is not serialized to the frontend. The collection argument is required because cleanup works on the complete collection. Prefer a dedicated named collection. You may explicitly choose collection: 'default', but deleteUnused: true is safe only when that collection belongs exclusively to this field.

The field method must follow imageUploads(). Pass disk: 's3' to override the collection's configured disk with another Laravel filesystem disk.

Form::save() holds the submitted RichText value out of mass assignment, stores its images, saves the rewritten HTML, and only then removes unused media. Raw upload markers are never written to the model.

RichText fields declared directly inside a Repeater or Blocks schema use the same workflow. Every submitted instance of one schema field contributes its stored image references before deleteUnused cleanup runs once for the collection. This prevents one row from deleting images still referenced by another row. The parent model attribute needs an array or JSON-compatible cast. Binding resolves the nested stored HTML for the editor without requiring a RichText cast on each nested value.

New models are saved once to create the Media Library owner and again after deferred RichText values are ready:

php
$post = $form->save(new Post);

The created event runs during the first save, before the RichText attribute contains its final stored HTML. See Saving Models for the full Eloquent lifecycle and initial column-value requirement.

Manual Processing

Use the richText() helper directly for a custom controller workflow:

php
$storedHtml = $form->richText('body')
    ->storeImagesInMediaLibrary(
        model: $post,
        collection: 'content-images',
    )
    ->toHtml();

$post->update(['body' => $storedHtml]);

The helper stores every image in the given collection, replaces its temporary src with the Media Library URL, and records a signed image reference for later rendering.

The model must already be saved. Create workflows should persist the model before moving its RichText images:

php
use Illuminate\Support\Arr;

$post = Post::create(
    Arr::except($form->validated(files: false), 'body'),
);

$post->body = $form->richText('body')
    ->storeImagesInMediaLibrary(
        model: $post,
        collection: 'content-images',
    )
    ->toHtml();

$post->save();

validated(files: false) omits file-backed values from mass assignment. Arr::except(..., 'body') also holds back the RichText HTML until its uploaded images have been stored and rewritten.

Loading Stored Images Into The Editor

Stored Media Library images include a signed reference that lets RichText identify their Media records on later submissions. Cast the model attribute to RichTextContent so a bound RichText field preserves those references automatically:

php
use Illuminate\Database\Eloquent\Model;
use InertiaUI\Forms\RichText\RichTextContent;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;

class Post extends Model implements HasMedia
{
    use InteractsWithMedia;

    protected function casts(): array
    {
        return [
            'body' => RichTextContent::class,
        ];
    }
}

The form may then bind the model directly:

php
public function forPost(Post $post): static
{
    return $this->bind($post);
}

Eloquent applies the cast while the form reads body. The RichText field then loads the current Media Library URLs and available srcset while retaining the signed references needed by the editor. Other field types are not converted.

Use toEditorHtml() for manual editor hydration without a bound cast. It is the intent-focused equivalent of calling preserveStoredImageReferences() before toHtml():

php
return $this->bind([
    'body' => RichTextContent::fromModel($post, 'body')->toEditorHtml(),
]);

Loading public HTML into the editor loses the Media Library identity. A later submission then treats each image as an ordinary URL image. With deleteUnused: true, its Media record may be removed as unused.

Rendering Saved Content

The two toHtml() methods sit on different sides of the save operation:

  • $form->richText('body') returns RichTextUploads. Its toHtml() method verifies and stores submitted images, removes temporary upload tokens, and returns HTML for database storage. A signed stored-image reference may remain so later edits can identify the image.
  • $post->body returns RichTextContent when the attribute uses the cast shown above. Its toHtml() method removes those stored references and returns HTML for public display.

Never save the raw submitted body when image uploads are enabled. Pass it through RichTextUploads so temporary upload tokens are verified and removed.

Store the $storedHtml returned by the upload processor. Render only the cast value:

php
$publicHtml = $post->body?->toHtml();

The cast uses the same public representation when the model is converted to an array or JSON.

Never render storage or editor HTML

Do not send raw submitted HTML, $storedHtml, toStoredHtml(), toEditorHtml(), or preserveStoredImageReferences()->toHtml() to a user-facing page. Those values may intentionally contain package-owned image references. Use RichTextContent::toHtml() for public output.

Removing the package-owned image attributes does not sanitize arbitrary HTML or authorize private image URLs. Sanitize untrusted HTML separately, and authorize the model and viewer before generating private media URLs.

Removing Unused Media

Images removed from the editor remain in the collection by default. Pass deleteUnused: true to keep an exclusively owned collection in sync with the stored HTML:

php
RichText::make('body')
    ->imageUploads()
    ->storeImagesInMediaLibrary(
        collection: 'content-images',
        deleteUnused: true,
    );

With Form::save(), cleanup runs only after every new image is stored and the rewritten model attribute is saved. An explicitly submitted empty or null body clears the collection. An absent field leaves both the attribute and its collection untouched. Only valid signed references for the same collection retain media.

Manual processing may enable the same behavior on the upload helper:

php
$post->body = $form->richText('body')
    ->storeImagesInMediaLibrary(
        model: $post,
        collection: 'content-images',
        deleteUnused: true,
    )
    ->toHtml();

$post->save();

The helper preserves each original filename. A successful rewrite deletes the staged upload. A failed rewrite removes newly created Media records and keeps the staged upload available for retry. The scheduled upload cleanup command remains the safety net for abandoned files.

Model and media writes are not atomic

Form::save() does not open a database transaction around Media Library and filesystem changes. A failed model write removes media created while preparing the RichText value and keeps its staged uploads for retry. Unused-media cleanup runs after the rewritten attribute is saved because a database rollback cannot restore deleted files.

A cleanup failure may therefore leave the model update and newly referenced media in place while old unused-media cleanup is incomplete. The exception is not swallowed. A failure after the first save of a new model may leave that model row in place with its ordinary attributes. Multiple media fields are also processed in order, so a later failure does not undo an earlier successful field. Use dedicated collections, handle the exception at the application boundary, and rely on the scheduled upload cleanup command for abandoned staging files.

Custom Storage and Deferred URLs

Add a durable identifier when the stored URL may change or must be generated for the current viewer. keepTokenized() requires every newly stored image to receive an identifier:

php
use Illuminate\Support\Facades\Storage;
use InertiaUI\Forms\FileUploads\SubmittedUpload;
use InertiaUI\Forms\RichText\RichTextImage;
use RuntimeException;

$storedHtml = $form->richText('body')
    ->keepTokenized()
    ->storeImagesUsing(function (
        SubmittedUpload $upload,
        RichTextImage $image,
    ): RichTextImage {
        $path = $upload->store(
            path: 'content-images',
            options: ['disk' => 'public'],
            deleteTemporary: false,
        );

        if ($path === false) {
            throw new RuntimeException('The image could not be stored.');
        }

        return $image
            ->src(Storage::disk('public')->url($path))
            ->identifier($path);
    })
    ->toHtml();

identifier() writes an application-owned identifier and optional metadata in a signed stored reference. Existing references survive subsequent submissions only when their signatures are valid.

Rendering Stored Images

Stored image references let the application refresh image URLs without rewriting the saved RichText HTML. Media Library models use fromModel(); applications with another storage layer may resolve identifiers themselves.

Media Library Images

A model implementing Spatie Media Library's HasMedia contract may render a RichText attribute directly with fromModel():

php
use InertiaUI\Forms\RichText\RichTextContent;

$publicHtml = RichTextContent::fromModel(
    model: $post,
    attribute: 'body',
)->toHtml();

The helper reads the attribute, resolves its stored images from media attached to that model, and checks the collection recorded in each reference. It then writes the public Media Library URL and available srcset. Normal rendering removes the internal stored-image references from the returned HTML.

fromMediaLibrary() writes the full URL and available srcset, then records the Media Library identifier. Existing editor dimensions are preserved. Images without editor dimensions receive the largest responsive variant's intrinsic size. The Media name becomes alt text only when the original image has no alt attribute. Editor-bound HTML must retain that identifier as described in Loading Stored Images Into The Editor.

Customizing Model Images

Use withImages() to customize images resolved by a cast attribute. The callback receives the stored reference, a RichTextImage builder already populated from Media Library, and the resolved Media model:

php
use InertiaUI\Forms\RichText\RichTextImage;
use InertiaUI\Forms\RichText\RichTextStoredImage;
use Spatie\MediaLibrary\MediaCollections\Models\Media;

$publicHtml = $post->body?->withImages(function (
    RichTextStoredImage $stored,
    RichTextImage $image,
    Media $media,
): void {
    $image
        ->fromMediaLibrary($media, conversion: 'web')
        ->attribute('sizes', '(min-width: 768px) 720px, 100vw')
        ->attribute('loading', 'lazy');
})->toHtml();

Mutating the supplied builder is enough; the callback does not need to return it. Returning another RichTextImage or Media instance replaces the supplied builder. Here, web is an application-defined Media Library conversion. The sizes value uses a 720-pixel slot from 768 pixels upward and the viewport width below that breakpoint. The loading attribute enables native lazy loading.

Automatic cast hydration uses Media Library's default URLs. Apply custom image resolution while loading an editor by binding an array and finishing the customized content with toEditorHtml():

php
return $this->bind([
    'body' => $post->body?->withImages(function (
        RichTextStoredImage $stored,
        RichTextImage $image,
        Media $media,
    ): void {
        $image->fromMediaLibrary($media, conversion: 'web');
    })->toEditorHtml(),
]);

Eager-load the model's media relation before binding or rendering collections of models to avoid one Media Library query per model.

Private Media

fromModel() initially populates the image with Media Library's durable URL and responsive sources. Private media needs a fresh authorized URL each time the content is rendered:

php
$publicHtml = RichTextContent::fromModel(
    model: $post,
    attribute: 'body',
)
    ->requireSignedImages()
    ->withImages(function (
        RichTextStoredImage $stored,
        RichTextImage $image,
        Media $media,
    ): void {
        $image
            ->src($media->getTemporaryUrl(now()->addMinutes(5)))
            ->attribute('srcset', null);
    })
    ->toHtml();

Authorize the model for the current tenant and viewer before rendering it. A per-image policy may also be enforced inside the callback. Removing srcset prevents public responsive URLs from remaining beside the private src.

The signed reference contains the application identifier and optional metadata, not the temporary upload token. now()->addMinutes(5) makes each generated URL expire five minutes after rendering.

Custom Image Resolution

Use replaceImagesUsing() for lower-level workflows that resolve their own stored identifiers instead of loading images from a Media Library model. This matches the filesystem identifier stored in the advanced example above:

php
use Illuminate\Support\Facades\Storage;

$publicHtml = RichTextContent::from($post->body)
    ->requireSignedImages()
    ->replaceImagesUsing(function (
        RichTextStoredImage $stored,
        RichTextImage $image,
    ): void {
        $image->src(
            Storage::disk('public')->url($stored->identifier()),
        );
    })
    ->toHtml();

The RichTextStoredImage passed to the callback exposes identifier(), all metadata(), a nested meta($key, $default) lookup, and the original image attributes(). The RichTextImage builder is mutable, so the callback does not need to return it. Returning a different RichTextImage replaces the builder; returning null without changing it preserves the original attributes.

Rendered HTML removes data-inertia-forms-image attributes by default, including unresolved and malformed references. Ordinary image attributes remain. Replacement callbacks may preserve a reference, remove it with attribute(RichTextImage::STORED_ATTRIBUTE, null), or replace it with identifier(). RichTextContent::toHtml() returns null when its input was null. It assumes temporary upload markers were already removed by RichTextUploads; it is not a replacement for processing submitted uploads.

Stored Image Reference Versions

The data-inertia-forms-* namespace is package-owned. RichTextUploads removes client-submitted package attributes before storage callbacks run.

RichTextImage::identifier() writes a signed version 2 reference using Laravel's app.key configuration value, usually set by APP_KEY. Verification also checks app.previous_keys, so key rotation keeps existing content readable. keepTokenized() preserves only verified existing references or references issued by the storage callback.

Stored image references are signed, not encrypted. Their identifiers and metadata can be decoded, so they must never contain secrets. Public rendering removes the references from the returned HTML.

A signature proves that the package issued a reference, not that the current user owns its image. Continue to authorize identifiers in the render callback.

Require a valid signature while rendering with requireSignedImages():

php
use Illuminate\Support\Facades\Storage;

$publicHtml = RichTextContent::from($post->body)
    ->requireSignedImages()
    ->replaceImagesUsing(
        fn (RichTextStoredImage $stored, RichTextImage $image): RichTextImage => $image->src(
            Storage::disk('public')->url($stored->identifier()),
        ),
    )
    ->toHtml();

Versions before 1.4 wrote unsigned version 1 references. Version 1 references remain readable from existing stored HTML. They are read-only legacy data: RichTextUploads never trusts an unsigned version 1 reference submitted by the browser. Signed version 1 references remain valid, and all newly issued references use signed version 2.

Removing package-owned attributes does not sanitize the remaining HTML. Continue to sanitize untrusted HTML before storing or rendering it. Image upload validation does not sanitize the RichText HTML. Pre-processing sanitizers should allow src, alt, title, width, height, and data-inertia-forms-upload. Tokenized content also needs data-inertia-forms-image until RichTextContent renders it.

Word and Character Count

Word Count

php
RichText::make('content')->showWordCount();

Character Count

php
RichText::make('content')->showCharacterCount();

Max Length

Set a character limit for the editor text:

php
RichText::make('content')
    ->maxLength(5000)
    ->showCharacterCount();

maxLength() configures Tiptap's visible-text limit. showCharacterCount() displays the current count.

maxLength() does not add a Laravel validation rule. Laravel string rules measure the submitted HTML markup, not only its visible text. Add server-side rules for the submitted value and use a custom rule or FormRequest hook to enforce a visible-text limit:

php
RichText::make('content')
    ->maxLength(5000)
    ->showCharacterCount()
    ->rules(['nullable', 'string']);
php
$request->validate([
    'content' => ['nullable', 'string'],
]);

$html = $request->string('content')->toString();

// Sanitize the HTML with your application's approved sanitizer before storing.
// Count text extracted from sanitized HTML when enforcing a visible-text limit.

Article Editor

Use placeholder() to set the empty-editor prompt:

php
RichText::make('body')
    ->label('Article Body')
    ->placeholder('Start writing...')
    ->full()
    ->linkAllowedProtocols(['https', 'mailto'])
    ->linkDefaultProtocol('https')
    ->linkTargetBlank()
    ->linkNoopener()
    ->minHeight(300)
    ->maxHeight(800)
    ->showWordCount()
    ->showCharacterCount()
    ->maxLength(10000)
    ->required();

Shared Field APIs

RichText fields also use Form Class for labels and help text, Model Binding for defaults and bound values, Validation for rules and precognition, Conditional Visibility, Authorization, and Styling for classes, layout, and part classes. Image uploads use the transport setup described in Choosing an Upload Path.

Using the Component Directly

You may render RichText from each stack's richtext subpath with local HTML state, or pass serialized field props during manual form rendering.

vue
<script setup lang="ts">
import { ref } from 'vue'
import {
  RichText,
  type RichTextLinkOptions,
  type RichTextToolbar,
} from '@inertiaui/form-vue/richtext'

const body = ref<string | null>('<p>Draft body</p>')
const locked = ref(false)
const hasError = ref(false)

const toolbar: RichTextToolbar = [
  'bold',
  'italic',
  '|',
  'bulletList',
  'orderedList',
  '|',
  'link',
  'image',
  '|',
  'undo',
  'redo',
]

const linkOptions: RichTextLinkOptions = {
  targetBlank: true,
  noopener: true,
  noreferrer: true,
  rel: ['nofollow'],
  allowedProtocols: ['https', 'mailto'],
  defaultProtocol: 'https',
  autoLink: true,
  linkOnPaste: true,
  allowTargetOverride: false,
}

const editorParts = {
  toolbar: 'border-b border-zinc-200',
  toolbarButton: 'data-[active=true]:text-blue-600',
  editor: 'min-h-48',
  footer: 'text-xs',
  dialog: 'max-w-lg',
}
</script>

<template>
  <RichText
    v-model="body"
    id="article-body"
    name="body"
    label="Article body"
    help="Draft the public article body."
    placeholder="Start writing..."
    :toolbar="toolbar"
    :min-height="300"
    :max-height="800"
    show-word-count
    show-character-count
    :max-length="10000"
    :link-options="linkOptions"
    required
    precognitive
    :disabled="locked"
    :invalid="hasError"
    :error="hasError ? 'Article body is required.' : null"
    badge="Draft"
    badge-class="bg-zinc-100 text-zinc-700"
    label-trailing="Required"
    label-trailing-class="text-zinc-500"
    tooltip="Shown on the article page."
    help-position="below"
    layout="stacked"
    control-position="end"
    label-class="font-medium"
    help-class="text-zinc-500"
    error-class="text-red-600"
    wrapper-class="max-w-3xl"
    control-class="bg-white"
    class="font-medium"
    :part-classes="editorParts"
  />
</template>
tsx
import { useState } from 'react'
import {
    RichText,
    type RichTextLinkOptions,
    type RichTextToolbar,
} from '@inertiaui/form-react/richtext'

const toolbar: RichTextToolbar = [
    'bold',
    'italic',
    '|',
    'bulletList',
    'orderedList',
    '|',
    'link',
    'image',
    '|',
    'undo',
    'redo',
]

const linkOptions: RichTextLinkOptions = {
    targetBlank: true,
    noopener: true,
    noreferrer: true,
    rel: ['nofollow'],
    allowedProtocols: ['https', 'mailto'],
    defaultProtocol: 'https',
    autoLink: true,
    linkOnPaste: true,
    allowTargetOverride: false,
}

const editorParts = {
    toolbar: 'border-b border-zinc-200',
    toolbarButton: 'data-[active=true]:text-blue-600',
    editor: 'min-h-48',
    footer: 'text-xs',
    dialog: 'max-w-lg',
}

export default function ArticleEditor() {
    const [body, setBody] = useState<string | null>('<p>Draft body</p>')
    const [locked] = useState(false)
    const [hasError] = useState(false)

    return (
        <RichText
            value={body}
            onValueChange={setBody}
            id="article-body"
            name="body"
            label="Article body"
            help="Draft the public article body."
            placeholder="Start writing..."
            toolbar={toolbar}
            minHeight={300}
            maxHeight={800}
            showWordCount
            showCharacterCount
            maxLength={10000}
            linkOptions={linkOptions}
            required
            precognitive
            disabled={locked}
            invalid={hasError}
            error={hasError ? 'Article body is required.' : null}
            badge="Draft"
            badgeClass="bg-zinc-100 text-zinc-700"
            labelTrailing="Required"
            labelTrailingClass="text-zinc-500"
            tooltip="Shown on the article page."
            helpPosition="below"
            layout="stacked"
            controlPosition="end"
            labelClass="font-medium"
            helpClass="text-zinc-500"
            errorClass="text-red-600"
            wrapperClass="max-w-3xl"
            controlClass="bg-white"
            class="font-medium"
            partClasses={editorParts}
        />
    )
}

RichText is a composite editor and does not forward arbitrary native attributes to an inner native element. See Native Attributes And Events. Image-upload runtime configuration uses File Uploads.

Editor Support Components

The richtext subpath also exports EditorShell, EditorToolbar, and EditorCountFooter with their prop types. These low-level presenters let custom RichText integrations reuse the package shell, toolbar semantics, count footer, native attributes, and styling hooks without pulling Tiptap into the root or components entry points.

This custom editor surface reuses the package shell and count footer:

vue
<script setup lang="ts">
import {
  EditorCountFooter,
  EditorShell,
  type EditorCountFooterState,
} from '@inertiaui/form-vue/richtext'

const footer: EditorCountFooterState = {
  show: true,
  wordLabel: '12 words',
  characterLabel: '74 characters',
  characterLimitLabel: '500',
}
</script>

<template>
  <EditorShell name="summary">
    <div
      class="min-h-40 p-3"
      contenteditable
      role="textbox"
      aria-label="Summary"
    ></div>

    <template #footer>
      <EditorCountFooter :footer="footer" />
    </template>
  </EditorShell>
</template>
tsx
import {
    EditorCountFooter,
    EditorShell,
    type EditorCountFooterState,
} from '@inertiaui/form-react/richtext'

const footer: EditorCountFooterState = {
    show: true,
    wordLabel: '12 words',
    characterLabel: '74 characters',
    characterLimitLabel: '500',
}

export default function SummaryEditor() {
    return (
        <EditorShell name="summary">
            <div
                className="min-h-40 p-3"
                contentEditable
                suppressContentEditableWarning
                role="textbox"
                aria-label="Summary"
            />

            <EditorCountFooter footer={footer} />
        </EditorShell>
    )
}
ComponentPublic options
EditorShellname writes data-editor-name; focused, invalid, and disabled select semantic states; classValue and controlClass extend shell classes. Vue provides toolbar, default, and footer slots. React renders children. Both forward native wrapper attributes.
EditorToolbaritems is the normalized control/separator/spacer order; buttons supplies each control's label, state, and action; disabled disables every control; dataAttributePrefix changes generated test/data attribute names; classValue and buttonClass extend toolbar styling. React additionally receives an icons map; Vue toolbar buttons carry their icon components. Native wrapper attributes are forwarded.
EditorCountFooterfooter.show controls rendering; wordLabel, characterLabel, and characterLimitLabel provide already-formatted display text; classValue extends footer styling. Both implementations forward native wrapper attributes.

For broader presenter customization, copy the shipped RichText presenter and see Customizing Presenters.