Skip to content

Text Editor

<loomi-text-editor> is a themeable rich-text editor web component with a native browser editing surface, a configurable toolbar, Loomi icons, Loomi tooltips, floating labels, inline validation, and native form submission.

The editor stores its value as HTML. That means users can format text visually, and your form or application receives markup such as <p>Hello <strong>world</strong></p>.

Terminal window
npm install @loomidev/text-editor lit
import "@loomidev/text-editor";
<loomi-text-editor name="comment" label="Comment"></loomi-text-editor>

Use placeholder for a hint inside the editing area:

<loomi-text-editor
name="comment"
label="Comment"
placeholder="Write a thoughtful response"
></loomi-text-editor>

The tools prop controls what appears in the toolbar.

For plain HTML, pass a comma-separated list:

<loomi-text-editor
label="Release notes"
tools="basic,headings,lists,link"
></loomi-text-editor>

For JavaScript frameworks, you can also assign an array property. This is nicer when the tools come from configuration:

const editor = document.querySelector("loomi-text-editor");
editor.tools = ["basic", "colors", "lists", "embed"];

Both forms are supported on purpose:

  • Use a comma-separated string for HTML, Blade, Astro, or server-rendered templates.
  • Use an array when you are already in JavaScript, React refs, Vue refs, Svelte actions, or another framework layer.

If tools is not set, the editor uses:

default = basic, headings, lists, align, embed

Use tools="all" for the complete toolbar, or tools="none" to hide the toolbar and use the editor as a plain rich-text field.

Tool valueWhat it shows
headingA block style picker with Body and H1-H6.
font-familyFont family picker.
font-sizeRelative font size picker.
boldBold text.
italicItalic text. italics is accepted as an alias.
underlineUnderlined text.
strikethroughStruck-through text. strike is accepted as an alias.
font-colorText color picker. color, text-color, and font-colour are accepted aliases.
highlight-colorHighlight/background color picker. highlight and highlight-colour are accepted aliases.
bullet-listDotted list. bullets, dots, and unordered-list are accepted aliases.
ordered-listNumbered list. numbers and numbered-list are accepted aliases.
align-leftLeft alignment.
align-centerCenter alignment. centre and align-centre are accepted aliases.
align-rightRight alignment.
align-justifyJustified alignment.
inline-codeInline code formatting.
superscriptSuperscript text.
subscriptSubscript text.
blockquoteBlockquote formatting.
code-blockPreformatted code block.
linkOpens a Loomi modal with URL and display text inputs.
imageOpens a Loomi modal with URL, alt text, and a loomi-filepicker image option.
videoOpens a Loomi modal with URL and a loomi-filepicker video option. YouTube and Vimeo URLs are normalized to embed URLs.
aiShows an AI generate button and dispatches loomi-ai-generate for your app to handle. generate and ai-generate are accepted aliases.

Groups let you keep templates readable.

GroupExpands to
defaultbasic, heading, lists, align, embed
basicbold, italic, underline, strikethrough
marksbasic, inline-code, superscript, subscript
colorsfont-color, highlight-color
fontfont-family, font-size
typographyheading, font-family, font-size, font-color, highlight-color
listsbullet-list, ordered-list
alignalign-left, align-center, align-right, align-justify
scriptsuperscript, subscript
codeinline-code, code-block
blocksblockquote, code-block
embedlink, image, video
mediaimage, video
allEverything listed above, including ai
noneNo toolbar

You can mix groups and individual values:

<loomi-text-editor
label="Article body"
tools="typography,basic,lists,blockquote,link,image"
></loomi-text-editor>

Duplicate tools are ignored, and the toolbar keeps a consistent Loomi order.

The editor includes H1-H6 through the heading tool, even though font-size also exists. They are not the same thing:

  • Use headings when the text has document structure, such as article titles, section headings, or email headings.
  • Use font size when you only want visual emphasis inside otherwise normal content.

This keeps submitted HTML more useful for accessibility, search, server-side rendering, and later content processing.

Toolbar buttons use <loomi-icon> where the shared icon registry has a suitable icon. Every toolbar icon control is wrapped in <loomi-tooltip>, so compact controls still explain themselves on hover or keyboard focus.

Some text-formatting controls, such as superscript and subscript, use short text labels when that is clearer than forcing an unrelated icon.

label renders above the editor. rows controls the minimum editor height before content pushes it taller.

<loomi-text-editor
name="bio"
label="Bio"
rows="6"
required
show-error-inline
error-message="Tell us a little about yourself"
></loomi-text-editor>

validate() returns true or false:

const editor = document.querySelector("loomi-text-editor");
saveButton.addEventListener("click", () => {
if (!editor.validate()) return;
// continue with a valid value
});

value is always HTML:

const editor = document.querySelector("loomi-text-editor");
editor.addEventListener("input", () => {
console.log(editor.value);
});

Example value:

Project update

The new dashboard is ready for review.

  • Confirm the final copy.
  • Share feedback before Friday.
<h2>Project update</h2>
<p>The <strong>new dashboard</strong> is ready for review.</p>
<ul>
<li>Confirm the final copy.</li>
<li>Share feedback before Friday.</li>
</ul>

<loomi-text-editor> is form-associated. Give it a name, and it submits with native FormData just like an input:

<form id="post-form">
<loomi-text-editor
name="body"
label="Post body"
tools="all"
required
></loomi-text-editor>
<button>Publish</button>
</form>
<script type="module">
import "@loomidev/text-editor";
const form = document.querySelector("#post-form");
form.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(form);
const html = data.get("body");
console.log(html);
});
</script>

Because the value is HTML, treat it as user-generated HTML.

Recommended flow:

  1. Validate that required content exists in the browser with required, validate(), or your form library.
  2. Submit value or new FormData(form).get(name) to your server.
  3. Sanitize the HTML on the server with your platform’s trusted HTML sanitizer.
  4. Store the sanitized HTML, or store both the original and sanitized versions if your moderation workflow needs that.
  5. When rendering saved content, render only sanitized HTML.

Do not trust client-side sanitizing as your only protection. The browser can be modified, requests can be replayed, and value can be assigned directly from JavaScript.

For simple previews where you only need plain text, convert HTML to text in your app:

function htmlToText(html) {
const div = document.createElement("div");
div.innerHTML = html;
return div.textContent || "";
}

The link, image, and video tools open a <loomi-modal> instead of using browser prompts.

  • Link inserts use <loomi-input> fields for the URL and optional display text.
  • Image inserts use <loomi-input> for an image URL and alt text, plus <loomi-filepicker> for choosing an image file.
  • Video inserts use <loomi-input> for a video URL, plus <loomi-filepicker> for choosing a video file.

Links are hardened with target="_blank" and rel="noopener noreferrer". Image and video URL fields accept HTTP, HTTPS, and relative URLs. YouTube and Vimeo links render as iframe embeds.

By default, a file chosen through loomi-filepicker is inserted as a data URL. That works for immediate previews and simple forms, but it inlines the whole file into the saved value and is stripped outright by any sanitizer whose media allowlist is HTTP/HTTPS only. Set uploadHandler to upload the file instead and insert the URL you get back.

uploadHandler is a property, not an attribute — a function can’t be expressed in markup, so assign it in JavaScript (the same shape as <loomi-input>’s dynamicMask).

const editor = document.querySelector("loomi-text-editor");
editor.uploadHandler = async (file, kind) => {
const body = new FormData();
body.append("file", file);
// `kind` is "image" or "video" — route each to the endpoint that validates it.
const response = await fetch(`/api/media/${kind}`, { method: "POST", body });
if (!response.ok) throw new Error("Upload failed. Please try again.");
const { url } = await response.json();
return url; // inserted as the <img>/<video> src
};
ArgumentDescription
fileThe File the user picked.
kind"image" or "video", matching the embed dialog that was opened.

Resolve the URL to insert, or resolve undefined to insert nothing. If the handler rejects or resolves undefined, the editor inserts nothing, leaves the dialog open, and shows a <loomi-notification> error toast — a thrown Error’s message is used as the toast body, so throw something you’re happy showing the author. Because the handler is your own code, its return value isn’t held to the HTTP/HTTPS allowlist that user-typed URLs are: relative storage paths and blob: preview URLs are inserted as-is, and only executable schemes (javascript:, vbscript:) are rejected.

Add no-file-upload to drop the file picker from the image and video dialogs, leaving URL entry only. Use it when the app accepts media library URLs exclusively, rather than showing a control that can’t do anything useful.

<loomi-text-editor tools="basic,embed" no-file-upload></loomi-text-editor>

If your product needs a full media library or custom link picker, keep image, video, or link out of tools and provide your own buttons outside the editor. Those buttons can update editor.value or use your own app-level insertion flow.

Add ai to tools to show a sparkles button in the toolbar. Aliases generate and ai-generate are also accepted when configuring tools.

<loomi-text-editor tools="basic,lists,ai"></loomi-text-editor>

When clicked, the editor dispatches loomi-ai-generate. LoomiUI does not call any AI provider itself — your app listens for the event, runs the request against OpenAI, Anthropic, a local model, or your own backend, then inserts the returned HTML through event.detail.insert(html).

editor.addEventListener("loomi-ai-generate", async (event) => {
const { html, selection, insert } = event.detail;
const prompt = selection
? `Improve this selected text while keeping the same meaning:\n\n${selection}`
: "Write a short introduction paragraph for this document.";
const result = await generateText({
html,
selection,
prompt,
});
insert(result.html);
});
DetailDescription
htmlThe editor’s current HTML value. Use this for full-document prompts such as summarize, expand, or rewrite.
selectionThe plain-text selection at click time, if any. Empty when the caret is collapsed or nothing is selected.
insert(html)Helper that restores the saved selection and inserts generated HTML at that point. If the user had text selected, replace or wrap that range in your handler before calling insert.

The editor saves the current range when the sparkles button is clicked. Call event.detail.insert(html) after your async request finishes and the generated markup will land at that saved position. This works whether the user selected a sentence, placed the caret mid-paragraph, or clicked with no selection (insertion happens at the caret).

Typical flows:

  • Selection present: send selection (and optionally surrounding html) to your model, then call insert() with the rewritten fragment.
  • No selection: treat html as document context and insert new content at the caret.
  • Replace vs append: insert() uses document.execCommand("insertHTML") under the hood. Pass only the fragment you want added or swapped in.
  • The button is disabled when the editor is disabled or readonly.
  • Handle errors in your listener — the editor will not show a built-in AI error state.
  • Sanitize model output before insertion if your provider can return raw HTML.
  • Keep prompts, API keys, and rate limiting in application code so the component stays provider-neutral.

This keeps LoomiUI provider-neutral while still giving users a real toolbar affordance.

Use variant="minimal" for a bottom-border-only editor:

<loomi-text-editor label="Notes" variant="minimal"></loomi-text-editor>

Use label-position="inside" to keep a compact label inside the top of the editor, with the toolbar and editable text displayed beneath it:

<loomi-text-editor label="Notes" label-position="inside"></loomi-text-editor>

For the library-wide baseline, see Foundations — Accessibility.

For the shared container and viewport rules, see Foundations — Responsive behavior.

For theme activation, token overrides, and contrast guidance, see Foundations — Dark mode.

Attribute / propertyDefaultDescription
name(blank)Submitted with the nearest form.
label(blank)Label above the editor.
label-positiondefaultdefault keeps the label above the editor; inside keeps a compact label inside its top edge.
placeholder(blank)Placeholder text shown when the editor is empty.
value(blank)Current value as HTML.
toolsdefaultComma-separated string attribute, or string array property.
rows3Minimum height in text rows.
requiredfalseMarks the editor required.
disabledfalseDisables editing and toolbar controls.
readonlyfalseMakes content readable but not editable.
error-message(blank)Message used when validation fails.
show-error-inlinefalseShows error-message under the field.
no-clearingfalseRemoves the default bottom margin.
variantdefaultdefault | minimal (bottom border only, no box)
no-file-uploadfalseHides the file picker in the image and video dialogs, leaving URL entry only.
.uploadHandler(unset)Property only. (file, kind) => Promise<string | undefined> — uploads a picked file and returns the URL to insert. Unset, files are inlined as data URLs.

Methods: focus(), validate(), checkValidity(), reportValidity().

CSS parts: field, toolbar, editor.

EventDescription
changeFired when the value is committed or changed.
inputFired while the value is edited.
loomi-ai-generateFired when the AI generation action is requested.

<loomi-text-editor> is a standard custom element, so it works in plain HTML, Blade, React, Vue, Angular, Svelte, Astro, and most other frameworks. Import the package once before the tag renders.

<script type="importmap">
{ "imports": { "lit": "https://esm.sh/lit@3.3.3", "lit/": "https://esm.sh/lit@3.3.3/" } }
</script>
<script type="module" src="https://esm.sh/@loomidev/text-editor"></script>
<loomi-text-editor name="notes" label="Notes" tools="basic,lists,embed"></loomi-text-editor>

React can render the custom element directly. If you want to pass an array to tools, assign it with a ref after mount.

import { useEffect, useRef } from "react";
import "@loomidev/text-editor";
export function Editor() {
const ref = useRef(null);
useEffect(() => {
ref.current.tools = ["basic", "colors", "lists", "embed"];
}, []);
return <loomi-text-editor ref={ref} name="body" label="Body" />;
}
<script setup>
import "@loomidev/text-editor";
</script>
<template>
<loomi-text-editor name="body" label="Body" tools="all" />
</template>

Add CUSTOM_ELEMENTS_SCHEMA, then use the tag in your template.

import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@loomidev/text-editor";
@Component({
selector: "app-root",
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `<loomi-text-editor name="body" label="Body" tools="all"></loomi-text-editor>`,
})
export class AppComponent {}
<script>
import "@loomidev/text-editor";
</script>
<loomi-text-editor name="body" label="Body" tools="typography,basic,embed"></loomi-text-editor>
---
import "@loomidev/text-editor";
---
<loomi-text-editor name="body" label="Body" tools="typography,basic,embed"></loomi-text-editor>

Frameworks such as Next.js, Nuxt, SvelteKit, and Astro may render HTML before browser-only custom elements run. If a framework complains, move the import to client-side code. In Next.js that usually means a component with "use client"; in Nuxt it often means a .client.ts plugin.

Run commands from the top-level components workspace:

Terminal window
pnpm --filter @loomidev/text-editor build
pnpm --filter @loomidev/text-editor typecheck
  • @loomidev/core
  • @loomidev/filepicker
  • @loomidev/icon
  • @loomidev/input
  • @loomidev/modal
  • @loomidev/select
  • @loomidev/theme
  • @loomidev/tooltip