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>.
npm install @loomidev/text-editor litimport "@loomidev/text-editor";Basic Usage
Section titled “Basic Usage”<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>Choosing Toolbar Tools
Section titled “Choosing Toolbar Tools”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, embedUse tools="all" for the complete toolbar, or tools="none" to hide the toolbar and
use the editor as a plain rich-text field.
Individual Tools
Section titled “Individual Tools”| Tool value | What it shows |
|---|---|
heading | A block style picker with Body and H1-H6. |
font-family | Font family picker. |
font-size | Relative font size picker. |
bold | Bold text. |
italic | Italic text. italics is accepted as an alias. |
underline | Underlined text. |
strikethrough | Struck-through text. strike is accepted as an alias. |
font-color | Text color picker. color, text-color, and font-colour are accepted aliases. |
highlight-color | Highlight/background color picker. highlight and highlight-colour are accepted aliases. |
bullet-list | Dotted list. bullets, dots, and unordered-list are accepted aliases. |
ordered-list | Numbered list. numbers and numbered-list are accepted aliases. |
align-left | Left alignment. |
align-center | Center alignment. centre and align-centre are accepted aliases. |
align-right | Right alignment. |
align-justify | Justified alignment. |
inline-code | Inline code formatting. |
superscript | Superscript text. |
subscript | Subscript text. |
blockquote | Blockquote formatting. |
code-block | Preformatted code block. |
link | Opens a Loomi modal with URL and display text inputs. |
image | Opens a Loomi modal with URL, alt text, and a loomi-filepicker image option. |
video | Opens a Loomi modal with URL and a loomi-filepicker video option. YouTube and Vimeo URLs are normalized to embed URLs. |
ai | Shows an AI generate button and dispatches loomi-ai-generate for your app to handle. generate and ai-generate are accepted aliases. |
Tool Groups
Section titled “Tool Groups”Groups let you keep templates readable.
| Group | Expands to |
|---|---|
default | basic, heading, lists, align, embed |
basic | bold, italic, underline, strikethrough |
marks | basic, inline-code, superscript, subscript |
colors | font-color, highlight-color |
font | font-family, font-size |
typography | heading, font-family, font-size, font-color, highlight-color |
lists | bullet-list, ordered-list |
align | align-left, align-center, align-right, align-justify |
script | superscript, subscript |
code | inline-code, code-block |
blocks | blockquote, code-block |
embed | link, image, video |
media | image, video |
all | Everything listed above, including ai |
none | No 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.
Headings vs Font Size
Section titled “Headings vs Font Size”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.
Icons and Tooltips
Section titled “Icons and Tooltips”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.
Labels, Height, and Validation
Section titled “Labels, Height, and Validation”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});Reading Values
Section titled “Reading Values”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>Submitting Values
Section titled “Submitting Values”<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>Handling Submitted HTML Safely
Section titled “Handling Submitted HTML Safely”Because the value is HTML, treat it as user-generated HTML.
Recommended flow:
- Validate that required content exists in the browser with
required,validate(), or your form library. - Submit
valueornew FormData(form).get(name)to your server. - Sanitize the HTML on the server with your platform’s trusted HTML sanitizer.
- Store the sanitized HTML, or store both the original and sanitized versions if your moderation workflow needs that.
- 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 || "";}Links, Images, and Videos
Section titled “Links, Images, and Videos”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.
Uploading picked files
Section titled “Uploading picked files”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};| Argument | Description |
|---|---|
file | The 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.
Turning file upload off
Section titled “Turning file upload off”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.
AI Generate Option
Section titled “AI Generate Option”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);});Event detail
Section titled “Event detail”| Detail | Description |
|---|---|
html | The editor’s current HTML value. Use this for full-document prompts such as summarize, expand, or rewrite. |
selection | The 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. |
Selection behavior
Section titled “Selection behavior”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 surroundinghtml) to your model, then callinsert()with the rewritten fragment. - No selection: treat
htmlas document context and insert new content at the caret. - Replace vs append:
insert()usesdocument.execCommand("insertHTML")under the hood. Pass only the fragment you want added or swapped in.
Integration notes
Section titled “Integration notes”- The button is disabled when the editor is
disabledorreadonly. - 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.
Field appearance
Section titled “Field appearance”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>Accessibility
Section titled “Accessibility”For the library-wide baseline, see Foundations — Accessibility.
Responsive behavior
Section titled “Responsive behavior”For the shared container and viewport rules, see Foundations — Responsive behavior.
Dark mode
Section titled “Dark mode”For theme activation, token overrides, and contrast guidance, see Foundations — Dark mode.
Attributes and Properties
Section titled “Attributes and Properties”| Attribute / property | Default | Description |
|---|---|---|
name | (blank) | Submitted with the nearest form. |
label | (blank) | Label above the editor. |
label-position | default | default 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. |
tools | default | Comma-separated string attribute, or string array property. |
rows | 3 | Minimum height in text rows. |
required | false | Marks the editor required. |
disabled | false | Disables editing and toolbar controls. |
readonly | false | Makes content readable but not editable. |
error-message | (blank) | Message used when validation fails. |
show-error-inline | false | Shows error-message under the field. |
no-clearing | false | Removes the default bottom margin. |
variant | default | default | minimal (bottom border only, no box) |
no-file-upload | false | Hides 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.
Events
Section titled “Events”| Event | Description |
|---|---|
change | Fired when the value is committed or changed. |
input | Fired while the value is edited. |
loomi-ai-generate | Fired when the AI generation action is requested. |
Framework integration
Section titled “Framework integration”<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.
Choose your framework
Section titled “Choose your framework”<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>Server-side rendering notes
Section titled “Server-side rendering notes”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.
Developing This Package
Section titled “Developing This Package”Run commands from the top-level components workspace:
pnpm --filter @loomidev/text-editor buildpnpm --filter @loomidev/text-editor typecheckDependencies
Section titled “Dependencies”@loomidev/core@loomidev/filepicker@loomidev/icon@loomidev/input@loomidev/modal@loomidev/select@loomidev/theme@loomidev/tooltip