# Unlayer Documentation
> Unlayer is an embeddable, white-label drag-and-drop editor SDK that lets your users design responsive emails, landing pages, documents, and popups inside your own application. It pairs the visual builders with built-in AI — an assistant that edits designs from a chat prompt and an importer that turns HTML or screenshots into editable templates — plus a server-side Cloud API for exporting designs to HTML, PDF, images, and ZIP. These docs cover embedding and configuring the JavaScript builders (with React, Angular, and Vue wrappers), the AI features, deep white-label customization, the Cloud API, developer tools (CLI, headless React Elements, and AI coding-assistant Skills), and the portable design JSON data model.
This file contains all documentation content in a single document following the llmstxt.org standard.
## Vanilla JS
# Vanilla JS Setup
This guide covers embedding Unlayer directly with plain HTML and JavaScript. No framework or build tools required.
---
## Step 1: Add the Embed Script
Include the Unlayer embed script in your HTML page:
```html
Unlayer Editor
```
---
## Step 2: Initialize the Editor
```html
```
---
## Step 3: Add Export and Save Buttons
```html
```
---
## Complete Example
```html
Unlayer Editor
```
---
## Configuration Options
Pass additional options to customize the builder:
```javascript
unlayer.init({
id: 'editor-container',
displayMode: 'email',
appearance: {
theme: 'modern_light',
},
features: {
textEditor: {
spellChecker: true,
},
},
});
```
---
## Loading a Saved Design
```javascript
// After the editor is ready
unlayer.addEventListener('editor:ready', function () {
var savedDesign = /* fetch from your backend */;
unlayer.loadDesign(savedDesign);
});
```
---
## What's Next
- Explore the full [Builders documentation](/builder/installation) for advanced options.
- Consider using a [framework wrapper](/builder/react-component) for richer integration.
---
## Get Started with Unlayer
Unlayer helps developers add content creation to their applications. Let users design emails, pages, images, and documents with drag-and-drop builders, while your app uses APIs and AI to generate, save, export, and automate content. Ship a complete content workflow without building the entire stack yourself.
Pick a product and follow the step-by-step guide, or choose your framework below.
## Framework Setup
Already know your stack? Wire up the SDK in your framework.
## How Unlayer Fits Together
---
## Export HTML
Now that you have set up the editor in your application and users are creating content, it's time to save the resulting work i.e HTML.
This is how you can export the design's HTML.
:::tip Save Button
Most applications will add a save button that calls this export function.
:::
```javascript
unlayer.exportHtml(function (data) {
var json = data.design; // design json
var html = data.html; // final html
// Do something with the json and html
});
```
## Callback Parameters
The exportHtml callback returns the following parameters.
| Name | Description |
| :--------- | :-------------------------------------------------------------------------------------------------------------------------------------------- |
| **design** | This is the JSON of the design |
| **html** | This is the full HTML of the design, starting with the opening `` tag to the closing `` tag. |
| **chunks** | This includes the chunks of HTML separately in case you want to build your own layout. Check the [chunk parameters](#chunk-parameters) below. |
---
## Chunk Parameters
The following chunks of HTML are available.
| Name | Description |
| :-------- | :--------------------------------------------------------------------------------------------------------------------------------------------------- |
| **body** | This is the body part of the HTML that is added inside the `` tags. |
| **css** | This is the CSS required for the design to render properly. You can add it inside the `` tags. |
| **js** | This is the JS required for the design to render properly. You can add it inside the `` tags. |
| **fonts** | This includes any web fonts or [custom fonts](../../advanced-configuration/font-management/custom-fonts.md) used in the design so you can load them. |
---
## Export Options
You can also pass certain options to this function as a second parameter.
### Cleanup
By default, we clean up the final HTML and remove unused HTML class names and CSS styles. If you want to disable that feature, you can pass `cleanup: false`.
```javascript
unlayer.exportHtml(
function (data) {
var json = data.design;
var html = data.html;
},
{
cleanup: true,
},
);
```
### Minify
By default, the HTML output is not minified or compressed. However, it is recommended to export the HTML minified and compressed as it can reduce the file size by up to 25%.
```javascript
unlayer.exportHtml(
function (data) {
var json = data.design;
var html = data.html;
},
{
minify: true,
},
);
```
### Merge Tags
If you want the [Merge Tags](../../advanced-configuration/dynamic-content/merge-tags.md) in your design to be replaced by different values, you can pass the `mergeTags` object to export options.
```javascript
unlayer.exportHtml(
function (data) {
var json = data.design;
var html = data.html;
},
{
mergeTags: {
first_name: 'John',
last_name: 'Doe',
},
},
);
```
### Inline Styles
Inline Styles options can be used to Inline the global css styles for links. This is useful in some instances for the email builder where Gmail or some other clients may not respect the link styles. By default, this is set to `false`.
```javascript
unlayer.exportHtml(
function (data) {
var json = data.design;
var html = data.html;
},
{
inlineStyles: true,
},
);
```
---
## Cloud API
You can also use our Cloud API if you want to export a design to HTML from a server-to-server API call. [Learn More](/server/cloud-api/reference#tag/export/operation/exportHtml)
---
## Export Image
Similar to the [Export HTML](./export-html.md) function, you can also export a design in image format. This would upload the generated image to your connected [File Storage](../../files-storage/file-storage/overview.md) and send the link to the callback function.
Note: Unlike HTML or plain text, images are not generated on the client. This function uses our cloud API to generate the image.
```javascript
unlayer.exportImage(function (data) {
var json = data.design; // design json
var imageUrl = data.url; // image url
// Do something with the image url
});
```
## Callback Parameters
The exportImage callback returns the following parameters.
| Name | Description |
| :--------- | :------------------------------------- |
| **design** | This is the JSON of the design |
| **url** | This is the URL of the generated image |
---
## Export Options
You can also pass certain options to this function as a second parameter.
### Merge Tags
If you want the [Merge Tags](../../advanced-configuration/dynamic-content/merge-tags.md) in your design to be replaced by different values, you can pass the `mergeTags` object to export options.
```javascript
unlayer.exportImage(
function (data) {
var json = data.design; // design json
var imageUrl = data.url; // image url
},
{
mergeTags: {
first_name: 'John',
last_name: 'Doe',
},
},
);
```
---
### Full Page
If you want the exported image to be the full page design, you can use this option. It is enabled by default.
```javascript
unlayer.exportImage(
function (data) {
var json = data.design; // design json
var imageUrl = data.url; // image url
},
{
fullPage: false,
},
);
```
---
## Cloud API
If you want to generate an image on the server-side, you can use our [Cloud API](/server/cloud-api/reference#tag/export/operation/exportImage).
```javascript
const fetch = require('node-fetch');
let url = 'https://api.unlayer.com/v2/export/image';
let options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Basic BASE64-ENCODED-API-KEY',
},
body: JSON.stringify({
displayMode: 'email',
design: {}, // json of your design
mergeTags: { first_name: 'John', last_name: 'Doe' },
}),
};
fetch(url, options)
.then((res) => res.json())
.then((json) => console.log(json))
.catch((err) => console.error('error:' + err));
```
Learn more at [Cloud API reference](/server/cloud-api/reference#tag/export/operation/exportImage).
### Response
The API response will return an uploaded image URL.
```json
{
"success": true,
"data": {
"url": "https://.../1593082715347-8LCcNZuuBpU4D92W.png"
}
}
```
---
## Export PDF
Similar to the [Export HTML](./export-html.md) function, you can also export a design in PDF format. This would upload the generated PDF to your connected [File Storage](../../files-storage/file-storage/overview.md) and send the link to the callback function.
Note: Unlike HTML or plain text, PDF is not generated on the client. This function uses our cloud API to generate the PDF.
```javascript
unlayer.exportPdf(function (data) {
var json = data.design; // design json
var pdfUrl = data.url; // pdf url
// Do something with the PDF url
});
```
## Callback Parameters
The exportPdf callback returns the following parameters.
| Name | Description |
| :--------- | :----------------------------------- |
| **design** | This is the JSON of the design |
| **url** | This is the URL of the generated PDF |
---
## Export Options
You can also pass certain options to this function as a second parameter.
### Merge Tags
If you want the [Merge Tags](../../advanced-configuration/dynamic-content/merge-tags.md) in your design to be replaced by different values, you can pass the `mergeTags` object to export options.
```javascript
unlayer.exportPdf(
function (data) {
var json = data.design; // design json
var pdfUrl = data.url; // pdf url
},
{
mergeTags: {
first_name: 'John',
last_name: 'Doe',
},
},
);
```
---
## Cloud API
If you want to generate the PDF on the server-side, you can use our [Cloud API](/server/cloud-api/reference#tag/export/operation/exportPdf).
```javascript
const fetch = require('node-fetch');
let url = 'https://api.unlayer.com/v2/export/pdf';
let options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Basic BASE64-ENCODED-API-KEY',
},
body: JSON.stringify({
displayMode: 'email',
design: {}, // json of your design
mergeTags: { first_name: 'John', last_name: 'Doe' },
}),
};
fetch(url, options)
.then((res) => res.json())
.then((json) => console.log(json))
.catch((err) => console.error('error:' + err));
```
Learn more at [Cloud API reference](/server/cloud-api/reference#tag/export/operation/exportPdf).
### Response
The API response will return an uploaded pdf URL.
```json
{
"success": true,
"data": {
"url": "https://.../1593082715347-8LCcNZuuBpU4D92W.pdf"
}
}
```
---
## Export Plain Text
Similar to [Export HTML](./export-html.md), you can also export a design in plain text. This is useful if you want to send an email in multiple formats for different email clients.
```javascript
unlayer.exportPlainText(function (data) {
var json = data.design; // design json
var text = data.text; // final text
// Do something with the json and text
});
```
## Callback Parameters
The exportPlainText callback returns the following parameters.
| Name | Description |
| :--------- | :---------------------------------------- |
| **design** | This is the JSON of the design |
| **text** | This is the full plain text of the design |
---
## Export Options
You can also pass certain options to this function as a second parameter.
### Omit Content
You can configure which parts of the design you want to omit in the plain text. For example, you can choose to ignore images, links, or preheader text.
```javascript
unlayer.exportPlainText(
function (data) {
var json = data.design;
var text = data.text;
},
{
ignoreLinks: true,
ignoreImages: true,
ignorePreheader: true,
},
);
```
| Name | Description |
| :------------------ | :---------------------------------------------- |
| **ignoreLinks** | Links and buttons will not be included |
| **ignoreImages** | Image alt text will not be included |
| **ignorePreheader** | Pre-header text for emails will not be included |
### Merge Tags
If you want the [Merge Tags](../../advanced-configuration/dynamic-content/merge-tags.md) in your design to be replaced by different values, you can pass the `mergeTags` object to export options.
```javascript
unlayer.exportPlainText(
function (data) {
var json = data.design;
var text = data.text;
},
{
mergeTags: {
first_name: 'John',
last_name: 'Doe',
},
},
);
```
---
## Export ZIP
Similar to the [Export HTML](./export-html.md) function, you can also export a design as a ZIP file. This generates a bundle ZIP file that also includes all the images required by your design and uploads it to your connected [File Storage](../../files-storage/file-storage/overview.md).
Note: Unlike HTML or plain text, ZIP file is not generated on the client. This function uses our cloud API to generate the ZIP file.
```javascript
unlayer.exportZip(function (data) {
var json = data.design; // design json
var fileUrl = data.url; // zip file url
// Do something with the ZIP url
});
```
## Callback Parameters
The exportZip callback returns the following parameters.
| Name | Description |
| :--------- | :---------------------------------------- |
| **design** | This is the JSON of the design |
| **url** | This is the URL of the generated ZIP file |
---
## Export Options
You can also pass certain options to this function as a second parameter.
### Merge Tags
If you want the [Merge Tags](../../advanced-configuration/dynamic-content/merge-tags.md) in your design to be replaced by different values, you can pass the `mergeTags` object to export options.
```javascript
unlayer.exportZip(
function (data) {
var json = data.design; // design json
var fileUrl = data.url; // zip file url
},
{
mergeTags: {
first_name: 'John',
last_name: 'Doe',
},
},
);
```
---
## Cloud API
If you want to generate the ZIP file on the server-side, you can use our [Cloud API](/server/cloud-api/reference#tag/export/operation/exportZip).
```javascript
const fetch = require('node-fetch');
let url = 'https://api.unlayer.com/v2/export/zip';
let options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Basic BASE64-ENCODED-API-KEY',
},
body: JSON.stringify({
displayMode: 'email',
design: {}, // json of your design
mergeTags: { first_name: 'John', last_name: 'Doe' },
}),
};
fetch(url, options)
.then((res) => res.json())
.then((json) => console.log(json))
.catch((err) => console.error('error:' + err));
```
Learn more at [Cloud API reference](/server/cloud-api/reference#tag/export/operation/exportZip).
### Response
The API response will return an uploaded zip file URL.
```json
{
"success": true,
"data": {
"url": "https://.../1593082715347-8LCcNZuuBpU4D92W.zip"
}
}
```
---
## Installation
This page will help you get started with Unlayer. You'll be up and running in a jiffy!
## Step 1: Add JavaScript Library
To get started, you need to add the Unlayer JavaScript library to your web app. This library will load everything needed to run the builder.
```html
```
If you are using **React**, **Angular** or **Vue**, check our components available on npm:
- [React Component](../libraries/react-component.md)
- [Angular Component](../libraries/angular-component.md)
- [Vue Component](../libraries/vue-component.md)
---
## Step 2: Add a Container for the Editor
The Unlayer editor needs a container element on your page. Add a blank div where the editor will be displayed.
```html
```
:::info Recommended Size
For the best experience, ensure the container is at least **1024px** wide and **700px** high. The editor will expand to fill the entire container.
:::
---
## Step 3: Initialize the Editor
Now, let’s initialize the editor inside the container you just created by passing its id and configuration options.
```javascript
unlayer.init({
id: 'editor-container', // ID of the container created in previous step
projectId: 1234, // Add your project ID here
displayMode: 'email', // Can be 'email', 'web' or 'popup'
});
```
### Where to get the Project ID?
1. Log in to the **[Developer Console](https://console.unlayer.com/)**
2. Create a **Project**
3. You can find the project ID in **Project > Settings**
---
### Configuration Options
Here's a breakdown of some commonly used configuration options for `unlayer.init()`:
| Attribute | Required | Description |
| -------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **id** _string_ | Yes | This is the HTML div id of the container where the editor will be embedded. |
| **displayMode** _string_ | No | This decides which builder to load. Available values: `email`, `web`, `popup`. Default value is `web`. We have these builders available: [Email Builder](../builders/email-builder/overview.md), [Page Builder](../builders/page-builder/overview.md), [Popup Builder](../builders/popup-builder/overview.md) |
| **projectId** _integer_ | No | This is the project ID from Unlayer. You can get it from the project settings page. |
| **locale** _string_ | No | This is the locale you want to load the editor in. We have many translations available. [Learn more](../advanced-configuration/localization.md) |
| **appearance** _object_ | No | These are the appearance options to change the look and feel of the editor. [Learn more](../customization/appearance/overview.md) |
| **user** _object_ | No | This is the info of the user who is using the editor. You can pass `id`, `name` and `email`. [Learn More](../advanced-configuration/end-user-identification.md). |
| **mergeTags** _array_ | No | This is an array of objects. You can pass the merge tags to display in the editor. [Learn more](../advanced-configuration/dynamic-content/merge-tags.md) |
| **designTags** _object_ | No | You can pass design tags in this object. [Learn more](../advanced-configuration/dynamic-content/design-tags.md) |
| **specialLinks** _object_ | No | You can pass special links in this object. [Learn More](../advanced-configuration/link-management/special-links.md) |
| **tools** _object_ | No | These are the options for tools and custom tools. [Learn more](../tools/overview.md) |
| **blocks** _array_ | No | This is an array of objects. You can pass custom blocks here. [Learn more](../blocks/custom-blocks.md) |
| **editor** _object_ | No | These are some editor options for different functions of the editor. [Learn more](../customization/editor-behavior/overview.md) |
| **fonts** _object_ | No | You can pass custom fonts here. [Learn More](../advanced-configuration/font-management/overview.md) |
| **customJS** _array_ | No | Custom JavaScript URL or source. [Learn more](../customization/appearance/custom-js-css.md) |
| **customCSS** _array_ | No | Custom CSS URL or source. [Learn more](../customization/appearance/custom-js-css.md) |
| **textDirection** _string_ | No | This is the text direction of html output which can be `rtl` or `ltr`. |
---
### Multiple Instances
You can create multiple instances of the editor on the same page, or if you have a single page application.
```javascript
const editor = unlayer.createEditor({
id: 'editor-container',
projectId: 1234, // Add your project ID here
displayMode: 'email',
});
editor.loadDesign({});
editor.exportHtml(function (data) {});
```
---
## Step 4: Deploy to Production
To access project settings and premium features in production:
1. Log in to **[Developer Console](https://console.unlayer.com)** and go to your project.
2. **Pass the `projectId`** when initializing the builder. You can find the project ID in console under Project > Settings.
3. **Add Allowed Domains**: Ensure your builder only works on specific domains. Go to Project > Settings > Deployment and add your allowed domains under “Allowed Domain(s)” section as shown below.
---
## Load and Save Designs
The builder provides APIs to **load**, **save**, and even **auto-save** designs in your application. This allows developers to manage and persist user-created designs seamlessly. When the builder is initialized, it loads with a blank design.
:::info JSON Format
All designs are loaded and saved in the JSON format
:::
## Load Design
To load a pre-existing design or template into the editor, you can use the `unlayer.loadDesign()` method. The design is passed as a JSON object.
**Example:**
```javascript
var design = {...}; // template JSON
unlayer.loadDesign(design);
```
- **design**: This is the JSON object that contains the design structure. When this method is called, the editor will load the specified design or template into the builder.
---
## Save Design
To save a design, you can use the `unlayer.exportHtml()` method. This method will export both the design’s HTML and its JSON structure. You can then save either or both, depending on your requirements.
**Example:**
```javascript
unlayer.exportHtml(function (data) {
var json = data.design; // The design JSON structure
var html = data.html; // The final HTML of the design
// Save the JSON and/or HTML
});
```
- **data.design**: This contains the design’s JSON structure, which can be stored for later use or modifications.
- **data.html**: This is the final HTML output that you can use to render the design on the web or in emails.
---
## Auto-Saving a Design
If you want to automatically save the design whenever the user makes changes, you can listen for the [`design:updated`](../advanced-configuration/events-callbacks.md#design-updated) event. This event is triggered every time the user updates the design in the editor.
**Example:**
```javascript
unlayer.addEventListener('design:updated', function (updates) {
// Design has been updated by the user
unlayer.exportHtml(function (data) {
var json = data.design; // The updated design JSON
var html = data.html; // The updated HTML
// Auto-save the JSON and/or HTML here
});
});
```
- **design:updated event**: This event is fired whenever the user modifies the design in the editor.
- **Auto-save logic**: After detecting changes, the design can be saved automatically in the background by exporting the design JSON and/or HTML.
---
## Best Practices for Saving Designs
- **Use the JSON**: The JSON structure is ideal for storing the design in a database because it allows you to reload and edit the design later.
- **Use the HTML**: The HTML can be used for rendering the final output on a website or in an email. It is typically not modifiable once saved.
---
## Example Use Cases
1. **Loading a Pre-Existing Template**: You might have a saved design template that you want to load into the editor for further customization by the user. The `unlayer.loadDesign()` method makes it easy to load and edit.
2. **Saving User-Generated Designs**: When a user finishes creating a design, you can save both the JSON for reloading/editing purposes and the HTML for displaying or sending as an email.
3. **Auto-Saving in Real-Time**: To avoid losing any user changes, set up auto-saving that listens for the `design:updated` event and automatically saves the current state of the design.
---
## Version Management
Unlayer provides flexibility for developers by offering different release channels for the builder and the ability to lock the builder to a specific version. By using these options, you can control which version of the builder is loaded in your application, whether it be the latest cutting-edge version or a stable release.
This is especially useful for developers who want to:
- Use the latest features and improvements as soon as they are available.
- Ensure stability by locking their application to a known stable version.
- Lock the builder to a specific version number to prevent unexpected updates.
---
## Release Channels
Unlayer supports two main release channels:
1. **Stable**: The default and most reliable release channel. This version contains thoroughly tested features and is recommended for production environments. It provides maximum stability and minimal changes over time.
2. **Latest**: The edge version that contains the latest features, bug fixes, and improvements. It is updated more frequently and includes the most recent changes. This is ideal for developers who want to test new features early.
---
## Using a Release Channel
You can specify which release channel or version of the builder to load by passing the version option when initializing the builder. By default, the builder loads the stable version unless otherwise specified.
```javascript
unlayer.init({
version: 'latest', // Specify 'stable' or a specific version number
});
```
### Options for version:
- **stable**: Loads the stable release of the Unlayer builder. This is the default option and is recommended for most production environments.
- **latest**: Loads the latest edge release, which includes new features, improvements, and bug fixes. This version may be less stable but includes the most up-to-date changes.
- **Specific Version Number**: You can lock the builder to a specific version by specifying a version number (e.g., 1.63.2). This is useful if you want to ensure that the builder doesn’t automatically update to a newer version, preventing potential breaking changes.
---
## Changelog
:::info Changelog
A versioned changelog is available here: [Changelog](/changelog)
:::
---
## Example Use Cases
### Production Environment
In a production environment, you may want to load the stable version to ensure that your users experience a reliable and consistent builder.
```javascript
unlayer.init({
version: 'stable', // Stable and reliable for production
});
```
### Development Environment
In a development or staging environment, you might use the latest release channel to test new features and improvements as they become available.
```javascript
unlayer.init({
version: 'latest', // Use the latest version for testing new features
});
```
### Version Locking
If your application relies on a specific version of the builder and you want to avoid any unexpected changes due to updates, you can lock the builder to a specific version number.
```javascript
unlayer.init({
version: '1.63.2', // Lock to a specific version
});
```
---
## Best Practices
- For production environments, we recommend using the **stable** release channel to minimize the risk of introducing bugs or instability due to frequent updates.
- Use the **latest** release channel in development environments to experiment with the newest features and provide feedback on upcoming changes.
- Lock the builder to a specific version if you require strict control over the features and stability of the editor in your application.
---
## Notes
- You can switch between release channels at any time by updating the version parameter in your initialization script.
- Version locking is ideal for applications where stability and predictability are critical, and you want to avoid the possibility of automatic updates affecting the end-user experience.
---
## Document Builder
The Document Builder allows developers to embed a powerful drag-and-drop editor into their web applications, enabling end-users to create and edit documents. The builder provides extensive tools and customization options for creating structured content such as reports, proposals, contracts, and more.

---
# Integration
After [installing](../../get-started/installation.md) our JavaScript library, use the following code snippet to initiate the email editor. You have to pass `displayMode` as `document`, and replace your project id.
```javascript
unlayer.init({
id: 'editor-container',
displayMode: 'document',
projectId: 1234, // REPLACE
});
```
---
# Builder In Action
Once the document builder is initialized, it will look like this.
---
# Tools for Documents
The builder comes with the following tools out of the box.
- **Columns**: It allows your users to add columns to your design in order to have a better design arrangement.
- **Button**: Add any type of button in your email. You can change colors and styles.
- **Divider**: It gives your users appropriate spacing at any point they want in their design.
- **Heading**: Add headings (from level 1-6) to the design.
- **Image**: To make your emails attractive, you can add images using this tool.
- **Social**: It is a built-in tool that lets users add their social media icons to your design.
- **Text**: Text is a built-in tool so users can add text to their designs.
- **Page Break**: To create multi-page documents, users can use this tool, which adds a dark dotted line to represent the start of a new line.
---
# Exporting PDF
Once the document is ready to be exported, you can use the [Export PDF](../../get-started/export/export-pdf.md) method to export it into a PDF file.
---
## Quickstart
# Document Builder Quickstart
This guide walks you through embedding Unlayer's drag-and-drop document builder into your web application. The document builder lets your users create reports, proposals, contracts, and other structured documents.
---
## Step 1: Install the SDK
Add the script tag to your HTML:
```html
```
```bash
npm install react-email-editor
```
```bash
npm install angular-email-editor
```
```bash
npm install vue-email-editor
```
---
## Step 2: Initialize the Editor
Set `displayMode` to `document` to load the document builder.
:::info Recommended Size
For the best experience, ensure the editor container is at least **1024px** wide and **700px** high. The editor will expand to fill the entire container.
:::
```html
```
```jsx
function App() {
const emailEditorRef = useRef(null);
const onReady = () => {
console.log('Editor is ready');
};
return (
);
}
export default App;
```
**app.module.ts**
```typescript
@NgModule({
imports: [ EmailEditorModule ],
})
```
**app.component.ts**
```typescript
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
})
export class AppComponent {
@ViewChild(EmailEditorComponent)
private emailEditor: EmailEditorComponent;
options = {
displayMode: 'document',
projectId: 1234, // Replace with your project ID
};
editorLoaded() {
console.log('Editor is ready');
}
exportHtml() {
this.emailEditor.editor.exportHtml((data) =>
console.log('exportHtml', data),
);
}
}
```
**app.component.html**
```html
```
```html
```
### Where to get the Project ID?
1. Log in to the **[Developer Console](https://console.unlayer.com/)**
2. Create a **Project**
3. You can find the project ID in **Project > Builder > Settings**
---
## What's Next
- [Load & Save Designs](/builder/load-and-save-designs) — Load templates and persist user work.
- [Export Designs](/builder/export-designs) — Export as HTML, PDF, images, and more.
- [Deploy to Production](/builder/installation#step-4-deploy-to-production) — Set up allowed domains and go live.
---
## Email Builder
With Unlayer’s email builder, your users don't have to spend hours in a designer's office creating emails. They can just drag and drop! Once the email is designed, your users can use your application to send it to their audiences.
Our email builder gives you full control over the email design. It lets you customize every aspect of the email, from the layout to the content and images. Your users can use images, merge tags, and other elements to customize their email. They can also preview the email before sending, and export the HTML code for further editing.

You can explore our [full template library](https://unlayer.com/templates).
## Why Use Email Builders?
With a drag-n-drop email builder, users can create and select multiple email templates, change the content, include pictures, and link to files. Furthermore, they can name the template and change its structure and styling. Once your users select a template, they can customize the content by editing it in the editor. Rich template selection in email builders allows customizing the email to suit the audience and branding.
Our email builder also **improves deliverability** and ensures that the email looks great in all **email clients**, especially older ones such as Outlook.
## Integration
After [installing](../../get-started/installation.md) our JavaScript library, use the following code snippet to initiate the email editor. You have to pass `displayMode` as `email`, and replace your project id.
```javascript
unlayer.init({
id: 'editor-container',
displayMode: 'email',
projectId: 1234, // REPLACE
});
```
## Builder In Action
Once the builder is initialized, it will look like this (the screenshot follows your light / dark mode preference):
## Tools for Email
The builder comes with the following tools out of the box:
- **Columns:** It allows your users to add columns to your design in order to have a better design arrangement.
- **Button:** Add any type of button in your email. You can change colors and styles.
- **Divider:** It gives your users appropriate spacing at any point they want in their design.
- **Heading:** Add headings (from level 1-6) to the design.
- **HTML:** This tool will give your users room to add custom HTML to the design.
- **Image:** To make your emails attractive, you can add images using this tool.
- **Menu:** Menu is a built-in tool used to create navigation menus.
- **Social:** It is a built-in tool that lets users add their social media icons to your design.
- **Text:** Text is a built-in tool so users can add text to their designs.
- **Timer:** Timer is used to create countdown timers. This tool creates a countdown GIF image to run a real time countdown.
- **Video:** It lets users add YouTube or Vimeo videos to their designs. This tool automatically generates thumbnails of videos for emails and ensures that it renders correctly on all platforms.
You can create [custom tools](../../tools/custom-tools/overview.md) to extend the functionality.
---
## Quickstart(Email-builder)
# Email Builder Quickstart
This guide walks you through embedding Unlayer's drag-and-drop email builder into your web application — from installation to saving the finished design.
---
## Step 1: Install the SDK
Add the script tag to your HTML:
```html
```
```bash
npm install react-email-editor
```
```bash
npm install angular-email-editor
```
```bash
npm install vue-email-editor
```
---
## Step 2: Initialize the Editor
:::info Recommended Size
For the best experience, ensure the editor container is at least **1024px** wide and **700px** high. The editor will expand to fill the entire container.
:::
```html
```
```jsx
function App() {
const emailEditorRef = useRef(null);
const onReady = () => {
console.log('Editor is ready');
};
return (
);
}
export default App;
```
**app.module.ts**
```typescript
@NgModule({
imports: [ EmailEditorModule ],
})
```
**app.component.ts**
```typescript
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
})
export class AppComponent {
@ViewChild(EmailEditorComponent)
private emailEditor: EmailEditorComponent;
options = {
displayMode: 'email',
projectId: 1234, // Replace with your project ID
};
editorLoaded() {
console.log('Editor is ready');
}
exportHtml() {
this.emailEditor.editor.exportHtml((data) =>
console.log('exportHtml', data),
);
}
}
```
**app.component.html**
```html
```
```html
```
### Where to get the Project ID?
1. Log in to the **[Developer Console](https://console.unlayer.com/)**
2. Create a **Project**
3. You can find the project ID in **Project > Builder > Settings**
---
## What's Next
- [Load & Save Designs](/builder/load-and-save-designs) — Load templates and persist user work.
- [Export Designs](/builder/export-designs) — Export as HTML, PDF, images, and more.
- [Deploy to Production](/builder/installation#step-4-deploy-to-production) — Set up allowed domains and go live.
---
## Test Email
The Unlayer Email Builder offers a feature that allows end-users to send a test email directly from the preview mode. This can be useful for testing the email design without having to export the content first. To enable the “Send Email” button in preview mode, the following steps must be followed:
---
## How to Enable?
To enable this feature, make sure the following steps have been taken.
### 1. Email Mode
This feature is only available when the builder is running in email mode. Ensure that the builder is properly configured with `displayMode` set to `email`.
### 2. Identify the User
For security and functionality purposes, the end-user must be identified with a valid email address. You can pass the email address of the end-user using the email property during the builder’s initialization. This helps identify the user for sending the email. This is the email address that the email will be sent at.
```javascript
unlayer.init({
user: {
id: 1,
signature: 'XXX',
name: 'John Doe', // optional
email: 'john.doe@acme.com',
},
});
```
### 3. Setup Identity Verification
Ensure that identity verification is enabled for the end-user. Identity verification helps to securely handle the user’s email address and prevent impersonation. [Learn More](../../advanced-configuration/security-settings.md)
### 4. Enable Feature
You can enable the feature by passing the following feature flag to `unlayer.init`.
```javascript
unlayer.init({
features: {
sendTestEmail: true,
},
});
```
---
## Embed Videos
Videos can be added to your page and played without having to leave the page. Unlayer editor supports video links from **YouTube** or **Vimeo**. The thumbnail is automatically fetched and embedded into your pages.
## Supported Platforms
- **YouTube**: Supports both standard youtube.com and shortened youtu.be links
- **Vimeo**: Supports standard vimeo.com links
## How to Embed a Video
1. Drag and drop the Video tool to your design
2. Click on the video to open the properties panel
3. Paste your YouTube or Vimeo video URL in the "Video URL" field
4. The video thumbnail will be automatically fetched and displayed
## Customization Options
You can customize the video player appearance with the following options:
- **Play Icon Type**: Choose between different play button styles
- **Play Icon Color**: Select the color of the play button
- **Play Icon Size**: Adjust the size of the play button
## Example URLs
```javascript
// YouTube URLs
https://www.youtube.com/watch?v=VIDEO_ID
https://youtu.be/VIDEO_ID
// Vimeo URLs
https://vimeo.com/VIDEO_ID
```
You can learn more about the video tool in the [documentation](../../tools/built-in-tools/video.md).
---
## Form Tool
Our page builder comes with a [Form tool](../../tools/built-in-tools/form.md) out of the box. This is helpful to capture information from visitors.
The form tool allows your users to create interactive forms on their web pages with just a few clicks. Each form is fully customizable - you can add multiple fields, rearrange their order, style their appearance, and configure form submission settings. Whether you need a simple contact form or a complex multi-field survey, the form tool provides all the necessary features to capture visitor information effectively.
## Available Fields
The form tool offers a variety of field types that you can add to your forms:
- **Email**: For collecting email addresses
- **Name**: For collecting user names
- **Phone Number**: For collecting contact numbers
- **Birthday**: For collecting dates of birth
- **Company**: For collecting organization names
- **Website**: For collecting website URLs
- **Zip Code**: For collecting postal codes
You can change the default available fields. [Learn More](../../tools/built-in-tools/form.md)
## How to Add Fields
1. Drag and drop the Form tool from the tools panel
2. Click on the form to open the properties panel
3. Under "Fields" section, click "Select..." to add new fields
4. Choose the desired field type from the dropdown menu
## Layout Customization
You can customize the form's appearance using the Layout options:
- **Width**: Adjust the form's width using the slider (0-100%)
- Each field can be customized with its own label and placeholder text
- Fields can be reordered
- Fields can be deleted
You can learn more about the form tool in the [documentation](../../tools/built-in-tools/form.md).
---
## Page Builder
Landing pages can help your users level up their sales funnels, nurture their audience, and convert visitors into customers. The most effective landing pages have a single focus, strong call to action, and are optimized for search engines and social media. Landing pages are often temporary or permanent and are part of a marketing strategy. A landing page might require visitors to fill out a form.

When creating a landing page, design plays a very important role. It should be clean and easy to navigate, giving visitors all the information they need.
The landing page should also be easy to convert with a single click. Information architecture is important as it can help your users to create a conversion-friendly page.
---
## How Is It Different from Email Builder?
Pages and email have many common functionalities. Our page builder has been designed from the ground up to output HTML that is optimized for all web browsers. In addition, your users will find a broad range of features for web pages, such as forms, embedded videos, and the ability to add scripts to your web pages.
Here are a few examples of web pages your customers can build with Unlayer:
- Product page
- Splash page
- Disclaimer page
- Registration page
- Newsletter subscription
- Customer survey
- Booking request
- Portfolio
- Thank you page
Our page builder also has the following features:
- Users can adjust the width of the page in **percentage** (%) or **pixels** (px)
- The side bar can be **collapsed** to get a **full page view**
- Users can preview their design in **different browsers**
- **HTML scripts** can also be added
- **[Embedded videos](./embed-videos.md)** can be played within the page
- **[Form tool](./form-tool.md)** is available to capture user input
---
## Integration
After [installing](../../get-started/installation.md) our JavaScript library, use the following code snippet to initiate the page builder. Just pass `displayMode` as `web`, and replace the project id.
```javascript
unlayer.init({
id: 'editor-container',
displayMode: 'web',
projectId: 1234, // REPLACE
});
```
## Editor In Action
The output screen of the page builder will look like this (the screenshot follows your light / dark mode preference):
---
## Quickstart(Page-builder)
# Page Builder Quickstart
This guide walks you through embedding Unlayer's drag-and-drop page builder into your web application. The page builder lets your users create landing pages, product pages, and other web content.
---
## Step 1: Install the SDK
Add the script tag to your HTML:
```html
```
```bash
npm install react-email-editor
```
```bash
npm install angular-email-editor
```
```bash
npm install vue-email-editor
```
---
## Step 2: Initialize the Editor
Set `displayMode` to `web` to load the page builder.
:::info Recommended Size
For the best experience, ensure the editor container is at least **1024px** wide and **700px** high. The editor will expand to fill the entire container.
:::
```html
```
```jsx
function App() {
const emailEditorRef = useRef(null);
const onReady = () => {
console.log('Editor is ready');
};
return (
);
}
export default App;
```
**app.module.ts**
```typescript
@NgModule({
imports: [ EmailEditorModule ],
})
```
**app.component.ts**
```typescript
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
})
export class AppComponent {
@ViewChild(EmailEditorComponent)
private emailEditor: EmailEditorComponent;
options = {
displayMode: 'web',
projectId: 1234, // Replace with your project ID
};
editorLoaded() {
console.log('Editor is ready');
}
exportHtml() {
this.emailEditor.editor.exportHtml((data) =>
console.log('exportHtml', data),
);
}
}
```
**app.component.html**
```html
```
```html
```
### Where to get the Project ID?
1. Log in to the **[Developer Console](https://console.unlayer.com/)**
2. Create a **Project**
3. You can find the project ID in **Project > Builder > Settings**
---
## What's Next
- [Load & Save Designs](/builder/load-and-save-designs) — Load templates and persist user work.
- [Export Designs](/builder/export-designs) — Export as HTML, PDF, images, and more.
- [Deploy to Production](/builder/installation#step-4-deploy-to-production) — Set up allowed domains and go live.
---
## Popup Builder
Popups are small windows that appear on your screen. Usually, they appear in the foreground of your visual interface. They are used to promote products and services on a website. However, many people are still not convinced by its effectiveness. Here are some benefits of popups for websites.
- Increase user engagement
- Increase conversion
- Decrease bounce rate
- Give a highly interactive Call To Action (CTA)
Due to high demand, we at Unlayer decided to add this feature to our long list of design modules.

---
## Why are Popups Used?
Popups help build lists. Most popups aim to collect email addresses, these are then used to send marketing messages to site visitors. This can lead to a higher number of conversions. Increasing user engagement with popups is a powerful way to increase conversions and lead generation. These small windows usually elicit a response from the user.
## Composition of a Popup
Popups are traditionally composed of the following things;
- Square or Rectangular Window
- Close Button
- Eye-Catching Images
When designing a popup, make sure your users have the right focus. Make the copy clear and simple. People don't like to be confused or spammed with popups. The call-to-action should be the most critical part of the popup.
## Features
Below are some of the main features of Unlayer popup builder.
### Countdown Timers
A countdown timer is a stopwatch that can be embedded in the popup, this feature is used to increase the hype of any website or product before launch.

### Merge Tags (Personalized Content)
Using personalized content feature allows your users to tailor popups based on specific customers' information.
[Merge Tags](../../advanced-configuration/dynamic-content/merge-tags.md) can be defined during the initialization of the editor.

### Ready To Go HTML
Our editor creates HTML output for popups that is ready for any website. Copy, paste, and go.
---
## Integration
After [installing](../../get-started/installation.md) our JavaScript library, use the following code snippet to initiate the email editor. You have to pass `displayMode` as `popup`, and replace your project id.
```javascript
unlayer.init({
id: 'editor-container',
displayMode: 'popup',
projectId: 1234, // REPLACE
});
```
### Multiple Popups
If you want to be able to create multiple popups for the same page, you should give each popup a unique identifier. This can be done by passing `popupId` when [exporting HTML](../../get-started/export/export-html.md). Doing this will ensure that there is no CSS conflict with other elements of the page.
```javascript
unlayer.exportHtml(console.log, {
popupId: 'UNIQUE-ID',
});
```
## Editor In Action
Once the popup builder is initialized, it will look like this.
---
## Styling Options
The popup will be in the middle of the screen, and there will be a **Popup tab** for styling options. These options are divided into the following sections.
### Popup
Popup sections helps your users to;
- Set the exact **position** of the popup panel.
- Adjust the **width** and **height** of the popup box. Height is set to auto mode by default.
- If a user clicks on 'show more options', they will be given additional options to make the edges of your popup box rounded.

### Content
The content panel contains alignment controls and other options for width, fonts, and colors. Your users can:
- Align the **position** of your popup (horizontal or vertical)
- Resize the **width** of your content box
- Edit **font family**
- Edit **text color**
- Edit **background color**
If your user clicks on 'show more options' they will be given an additional option to add an image in the background of the content box.

### Overlay
In this section, your users can change the color of the **overlay**.

### Close Button
Styling and positioning of the close button is important. The close button should be prominently visible so users are not annoyed. In this panel, your users can;
- Set the position of your close button in any of the four corners
- Set the background color of the button
- Adjust the margins and borders

### Display Delay
Timing of when the popup appears is important for user experience. In this panel, your users can set the exact timing for when the popup should appear (in seconds).

---
## Quickstart(Popup-builder)
# Popup Builder Quickstart
This guide walks you through embedding Unlayer's drag-and-drop popup builder into your web application. The popup builder lets your users design popups, modals, and overlays for lead capture, promotions, and announcements.
---
## Step 1: Install the SDK
Add the script tag to your HTML:
```html
```
```bash
npm install react-email-editor
```
```bash
npm install angular-email-editor
```
```bash
npm install vue-email-editor
```
---
## Step 2: Initialize the Editor
Set `displayMode` to `popup` to load the popup builder.
:::info Recommended Size
For the best experience, ensure the editor container is at least **1024px** wide and **700px** high. The editor will expand to fill the entire container.
:::
```html
```
```jsx
function App() {
const emailEditorRef = useRef(null);
const onReady = () => {
console.log('Editor is ready');
};
return (
);
}
export default App;
```
**app.module.ts**
```typescript
@NgModule({
imports: [ EmailEditorModule ],
})
```
**app.component.ts**
```typescript
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
})
export class AppComponent {
@ViewChild(EmailEditorComponent)
private emailEditor: EmailEditorComponent;
options = {
displayMode: 'popup',
projectId: 1234, // Replace with your project ID
};
editorLoaded() {
console.log('Editor is ready');
}
exportHtml() {
this.emailEditor.editor.exportHtml((data) =>
console.log('exportHtml', data),
);
}
}
```
**app.component.html**
```html
```
```html
```
### Where to get the Project ID?
1. Log in to the **[Developer Console](https://console.unlayer.com/)**
2. Create a **Project**
3. You can find the project ID in **Project > Builder > Settings**
---
## What's Next
- [Load & Save Designs](/builder/load-and-save-designs) — Load templates and persist user work.
- [Export Designs](/builder/export-designs) — Export as HTML, PDF, images, and more.
- [Deploy to Production](/builder/installation#step-4-deploy-to-production) — Set up allowed domains and go live.
---
## Hide Property Editors
In the builder, when an end-user selects a content item to edit, a panel opens that allows them to modify various properties of the selected content.
For example, the screenshot below shows a few properties such as: **Color**, **Text Align**, **Line Height**, **Underline**, **Padding**, etc.

As a developer, you have the ability to hide specific property editors from the editing panel. This is useful when you want to simplify the user interface or restrict access to certain configuration options based on your application’s needs.
## How to Hide?
To hide individual property editors, you can configure the properties object for the relevant tool. The following example demonstrates how to hide a specific property editor for the button tool:

```javascript
unlayer.loadEditor({
tools: {
button: {
properties: {
textAlign: {
editor: {
enabled: false,
},
},
},
},
},
});
```
### Example Explained:
- **tools**: The top-level object representing all the tools in the editor (such as images, text, etc.).
- **button**: Refers to the button tool
- **properties**: Represents the different properties within the button tool
- **textAlign**: Represents the specific property that controls the alignment (as shown in screenshot above)
- **editor.enabled**: By setting this to false, you are hiding the editor for this particular property from users.
---
## Use Cases
- **Simplifying the UI**: You can remove unnecessary property editors for a streamlined user experience.
- **Role-based Access**: Hide advanced property editors for users who do not need access to complex configuration options.
---
## Notes
- When an individual property editor is hidden, the end-user will no longer see or modify that specific property.
- This feature is useful for creating a simplified or restricted user experience, especially when certain property settings are not required.
---
## Hide Property Groups
In the builder, when an end-user selects a content item to edit, a panel opens that allows them to modify various properties of the selected content. These properties are organized into **Property Groups**, making it easier for end-users to navigate and edit specific content properties.
For example, the screenshot below shows two property groups called "**Text**" and "**Links**".

As a developer, you have the ability to hide specific property groups from the editing panel. This is useful when you want to simplify the user interface or restrict access to certain configuration options based on your application’s needs.
## How to Hide?
You can hide property groups by configuring the tools option when initializing the editor. The following example demonstrates how to hide a property group "**Button Options**" from the "**Button**" tool.

```javascript
unlayer.loadEditor({
tools: {
button: {
sections: {
buttonOptions: {
editor: {
enabled: false,
},
},
},
},
},
});
```
### Example Explained:
- **tools**: The top-level object representing all the tools in the editor (such as images, text, etc.).
- **button**: Refers to the button tool
- **sections**: Represents the different sections or groups of properties within the button tool
- **buttonOptions**: Represents the specific group of properties that control the button options in the screenshot above.
- **editor.enabled**: By setting this to false, you are hiding the editor for this particular property group from users.
---
## Use Cases
- **Simplifying the UI**: You can remove unnecessary property groups for a streamlined user experience.
- **Role-based Access**: Hide advanced property groups for users who do not need access to complex configuration options.
---
## Notes
- When a property group is hidden, the end-user will not be able to view or modify the properties within that group.
- Be cautious when hiding critical property groups, as it may limit the ability of your end-users to fully customize their content.
---
## Button
Button is a built-in tool so users can add buttons to their designs. This tool is supported for both display modes: emails and web pages.
:::warning
The text editor for buttons has been updated. Existing buttons in saved designs will continue using the legacy editor, while new buttons will use the updated editor.
:::
## Enable / Disable
Button tool is enabled by default but you can choose to disable it.
```javascript
unlayer.init({
tools: {
button: {
enabled: false,
},
},
});
```
---
## Link Types
Button tools comes with the following actions:
- Open Website
- Send Email
- Call Phone Number
- Send SMS
You can add custom link types as well. [Learn more](../../advanced-configuration/link-management/link-types.md)
---
## Default Values
Button tool comes with the following default values. You can choose to change any of these.
| Property | Default Value |
| :--------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| buttonColors | `{ color: "#FFFFFF", backgroundColor: "#3AAEE0", hoverColor: "#FFFFFF", hoverBackgroundColor: "#3AAEE0" }` |
| padding | `10px 20px` |
| textAlign | `center` |
| size | `{ autoWidth: true, fullWidthOnMobile: false, width: '50%', }` |
| lineHeight | `120%` |
| borderRadius | `4px` |
| containerPadding | `10px` |
| textJson | `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Button Text","type":"extended-text","version":1}],"format":"","indent":0,"type":"extended-paragraph","version":1,"textFormat":0,"isInlineTool":true}],"format":"","indent":0,"type":"root","version":1}}` |
Here are a few examples on how to change these default values.
### Text
```javascript
unlayer.init({
tools: {
button: {
properties: {
textJson: {
value:
'{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Custom Text","type":"extended-text","version":1}],"format":"","indent":0,"type":"extended-paragraph","version":1,"textFormat":0,"isInlineTool":true}],"format":"","indent":0,"type":"root","version":1}}',
},
},
},
},
});
```
---
### Button Colors
```javascript
unlayer.init({
tools: {
button: {
properties: {
buttonColors: {
value: {
color: '#FFFFFF',
backgroundColor: '#3AAEE0',
hoverColor: '#FFFFFF',
hoverBackgroundColor: '#3AAEE0',
},
},
},
},
},
});
```
---
### Padding
```javascript
unlayer.init({
tools: {
button: {
properties: {
padding: {
value: '10px 20px',
},
},
},
},
});
```
---
### Text Alignment
```javascript
unlayer.init({
tools: {
button: {
properties: {
textAlign: {
value: 'center',
},
},
},
},
});
```
---
## Carousel
Carousel is a built-in tool that allows end-users to insert a rotating slider (image carousel) into their designs. This tool is supported for both display modes: emails and web pages. **For email designs, carousel is only supported with AMP enabled**.
### Usage
Enabling Carousel tool is a bit different than other built-in tools. Follow the steps below to enable and preview Carousel tool in your designs.
#### For Email Designs (AMP Required)
For email designs, the carousel tool requires AMP to be enabled:
- **Enable AMP for Carousel tool in the Builder Configuration**: To activate AMP, set the `amp` flag to `true` in the `unlayer.init()` function or the `options` prop if you're using the React component.
Here’s the code for reference.
**JavaScript Example**:
```js
unlayer.init({
id: 'editor-container',
projectId: 123456, // replace with project ID with yours
displayMode: 'email',
version: 'latest',
amp: true,
});
```
**React Example**:
```jsx
```
- **Use the Preview Mode to see Carousel in action**: You can preview the **carousel** tool within the builder by toggling the **AMP Preview** button in the editor UI.

- **Exporting HTML for Carousel functionality**: To export the HTML for Carousel tool, use the `exportHtml()` function with the `{ amp: true }` option. This will return both standard and AMP-compliant HTML in the export payload.
```js
unlayer.exportHtml(
function (data) {
var json = data.design;
var html = data.html;
var ampHTML = data.amp.html; // extract the HTML with AMP
console.log('Design JSON:', json);
console.log('Design AMP HTML:', ampHTML);
console.log('Design HTML:', html);
},
{ amp: true }, // enables AMP in the HTML output, used for Carousel tool
);
```
#### For Web Page Designs
For web page designs, simply set the `displayMode` to `web` in your builder configuration:
**JavaScript Example**:
```js
unlayer.init({
id: 'editor-container',
projectId: 123456, // replace with project ID with yours
displayMode: 'web',
version: 'latest',
});
```
**React Example**:
```jsx
```
---
## Columns
Columns tool is different from other built-in tools. It is not a content element but rather a block that allows end-users to quickly add a new empty block and choose its column configuration.
## Enable / Disable
Columns tool is enabled by default but you can choose to disable it.
```javascript
unlayer.init({
tools: {
rows: {
enabled: false,
},
},
});
```
---
## Divider
Divider is a built-in tool so users can add dividers to their designs. This tool is supported for both display modes: emails and web pages.
## Enable / Disable
Divider tool is enabled by default but you can choose to disable it.
```javascript
unlayer.init({
tools: {
divider: {
enabled: false,
},
},
});
```
---
## Default Values
Divider tool comes with the following default values. You can choose to change any of these.
| Property | Default Value |
| :--------------- | :------------------------------------------------------------------------------------- |
| width | `100%` |
| border | `{ borderTopWidth: '1px', borderTopStyle: 'solid', borderTopColor: '#BBBBBB', }` |
| textAlign | `center` |
| containerPadding | `10px` |
Here are a few examples on how to change these default values.
### Width
```javascript
unlayer.init({
tools: {
divider: {
properties: {
width: {
value: '100%',
},
},
},
},
});
```
---
### Border
```javascript
unlayer.init({
tools: {
divider: {
properties: {
border: {
value: {
borderTopWidth: '1px',
borderTopStyle: 'solid',
borderTopColor: '#BBBBBB',
},
},
},
},
},
});
```
---
### Alignment
```javascript
unlayer.init({
tools: {
divider: {
properties: {
textAlign: {
value: 'center',
},
},
},
},
});
```
---
### Container Padding
```javascript
unlayer.init({
tools: {
divider: {
properties: {
containerPadding: {
value: '10px',
},
},
},
},
});
```
---
## Form
Form is a built-in tool for our page builder. It lets users create and add forms to their landing pages. These could be used to capture leads, sign up, contact, and a variety of other use cases.
## Enable / Disable
Form tool is enabled by default but you can choose to disable it.
```javascript
unlayer.init({
tools: {
form: {
enabled: false,
},
},
});
```
---
## Usage Limit
You can put a limit to how many times Form tool can be used in a single design.
```javascript
unlayer.init({
tools: {
form: {
usageLimit: 1,
},
},
});
```
---
## Default Values
Form tool comes with the following default values. You can choose to change any of these.
| Property | Default Value |
| :------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| fields | `[ { "name": "email", "type": "email", "label": "Email", "placeholder_text": "Enter email here", "show_label": true, "required": true } ]` |
| formWidth | `100%` |
| fieldBorder | `{ borderTopWidth: '1px', borderTopStyle: 'solid', borderTopColor: '#CCC', borderLeftWidth: '1px', borderLeftStyle: 'solid', borderLeftColor: '#CCC', borderRightWidth: '1px', borderRightStyle: 'solid', borderRightColor: '#CCC', borderBottomWidth: '1px', borderBottomStyle: 'solid', borderBottomColor: '#CCC', }` |
| fieldBorderRadius | `0px` |
| fieldPadding | `10px` |
| fieldBackgroundColor | `#FFFFFF` |
| fieldColor | `#000000` |
| fieldFontSize | `12px` |
| formAlign | `center` |
| fieldDistance | `10px` |
| placeholderAlign | `left` |
| labelFontSize | `14px` |
| labelColor | `#444` |
| labelAlign | `left` |
| labelPadding | `0px 0px 3px` |
| buttonText | `Submit` |
| buttonColors | `{ color: '#FFF', backgroundColor: '#3AAEE0', hoverColor: '#FFF', hoverBackgroundColor: '#3AAEE0' }` |
| buttonAlign | `center` |
| buttonWidth | `50%` |
| buttonFontSize | `14px` |
| buttonBorder | `{}` |
| buttonPadding | `10px` |
| buttonMargin | `5px 0px 0px` |
Here are a few examples on how to change these default values.
### Fields
When someone adds a form to their design, it only has the Email field added by default. You can change that and add more fields.
```javascript
unlayer.init({
tools: {
form: {
properties: {
fields: {
value: [
{
name: 'first_name',
type: 'text',
label: 'First Name',
placeholder_text: 'Enter first name here',
show_label: true,
required: true,
},
{
name: 'last_name',
type: 'text',
label: 'Last Name',
placeholder_text: 'Enter last name here',
show_label: true,
required: false,
},
{
name: 'email',
type: 'email',
label: 'Email',
placeholder_text: 'Enter email here',
show_label: true,
required: true,
},
],
},
},
},
},
});
```
---
### Form Width
```javascript
unlayer.init({
tools: {
form: {
properties: {
formWidth: {
value: '100%',
},
},
},
},
});
```
---
### Field Border
```javascript
unlayer.init({
tools: {
form: {
properties: {
fieldBorder: {
value: {
borderTopWidth: '1px',
borderTopStyle: 'solid',
borderTopColor: '#CCC',
borderLeftWidth: '1px',
borderLeftStyle: 'solid',
borderLeftColor: '#CCC',
borderRightWidth: '1px',
borderRightStyle: 'solid',
borderRightColor: '#CCC',
borderBottomWidth: '1px',
borderBottomStyle: 'solid',
borderBottomColor: '#CCC',
},
},
},
},
},
});
```
---
### Field Border Radius
```javascript
unlayer.init({
tools: {
form: {
properties: {
fieldBorderRadius: {
value: '0px',
},
},
},
},
});
```
---
### Field Padding
```javascript
unlayer.init({
tools: {
form: {
properties: {
fieldPadding: {
value: '10px',
},
},
},
},
});
```
---
## Preset Fields
When a user tries to add a new field, there are some preset fields available in the dropdown. You can change those preset fields if you like.
```javascript
unlayer.init({
tools: {
form: {
properties: {
fields: {
editor: {
data: {
defaultFields: [
{ name: 'birthday', label: 'Birthday', type: 'date' },
{ name: 'company', label: 'Company', type: 'text' },
{ name: 'email', label: 'Email', type: 'email' },
{ name: 'first_name', label: 'First Name', type: 'text' },
{ name: 'last_name', label: 'Last Name', type: 'text' },
{ name: 'phone_number', label: 'Phone Number', type: 'text' },
{ name: 'website', label: 'Website', type: 'text' },
{ name: 'zip_code', label: 'Zip Code', type: 'text' },
],
},
},
},
},
},
},
});
```
---
## New Custom Fields
By default, users can add a custom field if they want. You can disable this behavior.
```javascript
unlayer.init({
tools: {
form: {
properties: {
fields: {
editor: {
data: {
allowAddNewField: true,
},
},
},
},
},
},
});
```
---
## Submit Actions
You can control where you want the form to submit, or you can let the end-users add a custom URL.
## Pre-Defined Actions
You can add pre-defined actions for the form submission end-point. If there are multiple actions, the users will see a dropdown to pick one. Otherwise, it will just default to the one you provide.
Each action needs to have the following attributes.
| Attribute | Description |
| :--------- | :------------------------------------------------------------------------------------------------------------------- |
| **label** | This is the label that the users will see in the dropdown if there are multiple actions. |
| **method** | This is the HTTP method for your end-point. You can add `GET` or `POST`. |
| **url** | This is the URL where the form will submit. |
| **target** | This is target window where the form will submit. If you want the form to submit in a new tab, you can add `_blank`. |
```javascript
unlayer.init({
tools: {
form: {
properties: {
action: {
editor: {
data: {
actions: [
{
label: 'Marketing',
method: 'POST',
url: 'http://whatever.com/marketing-form-submission',
},
{
label: 'Sales',
method: 'POST',
target: '_blank',
url: 'http://whatever.com/sales-form-submission',
},
],
},
},
},
},
},
},
});
```
## Custom URL
You can let the end-user add a custom URL for the submit action. To enable this, you can set `allowCustomUrl` to true.

```javascript
unlayer.init({
tools: {
form: {
properties: {
action: {
editor: {
data: {
allowCustomUrl: true,
},
},
},
},
},
},
});
```
---
## Heading
Heading is a built-in tool so users can add headings to their designs. This tool is supported for both display modes: emails and web pages.
:::warning
The text editor for headings has been updated. Existing headings in saved designs will continue using the legacy editor, while new headings will use the updated editor.
:::
## Enable / Disable
Heading tool is enabled by default but you can choose to disable it.
```javascript
unlayer.init({
tools: {
heading: {
enabled: false,
},
},
});
```
---
## Default Values
Heading tool comes with the following default values. You can choose to change any of these.
| Property | Default Value |
| :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| textJson | `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Heading","type":"extended-text","version":1}],"format":"","indent":0,"type":"extended-paragraph","version":1,"textFormat":0,"isInlineTool":true}],"format":"","indent":0,"type":"root","version":1}}` |
| headingType | `h1` |
| fontFamily | `{ label: 'Arial', value: 'arial,helvetica,sans-serif'}` |
| textAlign | `left` |
| color | `#000000` |
| lineHeight | `140%` |
| containerPadding | `10px` |
Here are a few examples on how to change these default values.
### Text
```javascript
unlayer.init({
tools: {
heading: {
properties: {
textJson: {
value:
'{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"This is a different heading","type":"extended-text","version":1}],"format":"","indent":0,"type":"extended-paragraph","version":1,"textFormat":0,"isInlineTool":true}],"format":"","indent":0,"type":"root","version":1}}',
},
},
},
},
});
```
---
### Alignment
```javascript
unlayer.init({
tools: {
heading: {
properties: {
textAlign: {
value: 'right',
},
},
},
},
});
```
---
### Color
```javascript
unlayer.init({
tools: {
heading: {
properties: {
color: {
value: '#000000',
},
},
},
},
});
```
---
### Line Height
```javascript
unlayer.init({
tools: {
heading: {
properties: {
lineHeight: {
value: '140%',
},
},
},
},
});
```
---
### Container Padding
```javascript
unlayer.init({
tools: {
heading: {
properties: {
containerPadding: {
value: '10px',
},
},
},
},
});
```
---
## HTML
HTML is a built-in tool so users can add custom HTML to their designs. This tool is supported for both display modes: emails and web pages.
## Enable / Disable
HTML tool is enabled by default but you can choose to disable it.
```javascript
unlayer.init({
tools: {
html: {
enabled: false,
},
},
});
```
---
## CodeMirror Options
[CodeMirror](https://codemirror.net/) is the code editor component used for the HTML editor. It has a variety of configuration options that you can override.
In this example code, we will disable the line wrapping in the HTML editor.
```javascript
unlayer.init({
tools: {
html: {
properties: {
html: {
editor: {
widgetParams: {
codeMirrorOptions: {
lineWrapping: false,
},
},
},
},
},
},
},
});
```
---
## Default Values
HTML tool comes with the following default values. You can choose to change any of these.
| Property | Default Value |
| :--------------- | :------------------------------- |
| html | `Hello, world!` |
| containerPadding | `10px` |
Here are a few examples on how to change these default values.
### HTML
```javascript
unlayer.init({
tools: {
html: {
properties: {
html: {
value: 'Hello, world!',
},
},
},
},
});
```
---
### Container Padding
```javascript
unlayer.init({
tools: {
html: {
properties: {
containerPadding: {
value: '10px',
},
},
},
},
});
```
---
## HTML Sanitization
Setting `safeHtml: true` will remove invalid HTML structures to prevent potential security issues and ensure valid HTML output.
### Table Sanitization
For example, if you input an invalid table structure like:
```html
Invalid table
```
It will be removed because it's missing the required `` element. A valid table structure should be:
```html
Valid table
```
If you need to disable this sanitization, you can set `safeHtml: false` in your initialization options.
---
## Image
Image is a built-in tool so users can add images to their designs. This tool is supported for both display modes: emails and web pages.
## Enable / Disable
Image tool is enabled by default but you can choose to disable it.
```javascript
unlayer.init({
tools: {
image: {
enabled: false,
},
},
});
```
---
## Default Values
Image tool comes with the following default values. You can choose to change any of these.
| Property | Default Value |
| :--------------- | :----------------------------------------------------------------------------------------- |
| src | `{ url: 'https://via.placeholder.com/500x100?text=IMAGE', width: 500, height: 100 }` |
| textAlign | `center` |
| altText | `Image` |
| containerPadding | `center` |
Here are a few examples on how to change these default values.
### Source
```javascript
unlayer.init({
tools: {
image: {
properties: {
src: {
value: {
url: 'https://via.placeholder.com/500x100?text=IMAGE',
width: 500,
height: 100,
},
},
},
},
},
});
```
---
### Alignment
```javascript
unlayer.init({
tools: {
image: {
properties: {
textAlign: {
value: 'center',
},
},
},
},
});
```
---
### Alternate Text
```javascript
unlayer.init({
tools: {
image: {
properties: {
altText: {
value: 'Image',
},
},
},
},
});
```
---
### Container Padding
```javascript
unlayer.init({
tools: {
image: {
properties: {
containerPadding: {
value: '10px',
},
},
},
},
});
```
---
## Placeholder
You can set a placeholder image for the image tool. This image will be displayed when the image URL is empty.
```javascript
unlayer.init({
image: {
properties: {
src: {
editor: {
widgetParams: {
placeholderUrl: 'IMAGE_PLACEHOLDER_URL',
},
},
},
},
},
});
```
---
## Menu
Menu is a built-in tool used to create navigation menus. It is supported in both display modes: email and web pages.
## Enable / Disable
Menu tool is enabled by default but you can choose to disable it.
```javascript
unlayer.init({
tools: {
menu: {
enabled: false,
},
},
});
```
---
## Default Values
Menu tool comes with the following default values. You can choose to change any of these.
| Property | Default Value |
| :--------- | :------------------------------------------------------------ |
| menu | `{ items: [] }` |
| fontFamily | `{ label: 'Arial', value: 'arial,helvetica,sans-serif' }` |
| fontSize | `14px` |
| textColor | `#444444` |
| linkColor | `#0068A5` |
| align | `center` |
| layout | `horizontal` |
| separator | ` ` |
| padding | `5px 15px` |
Here are a few examples on how to change these default values.
### Menu Items
You can set the menu tool to come with default menu items and their URLs pre-filled.
```javascript
unlayer.init({
tools: {
menu: {
properties: {
menu: {
value: {
items: [
{
key: `${Date.now() + Math.floor(Math.random() * 100)}`, // unique key
text: 'Home Page',
link: {
name: 'web',
values: {
href: 'https://mysite.com',
target: '_self',
},
},
},
{
key: `${Date.now() + Math.floor(Math.random() * 100)}`, // unique key
text: 'About Us',
link: {
name: 'web',
values: {
href: 'https://mysite.com/about-us',
target: '_self',
},
},
},
],
},
},
},
},
},
});
```
---
### Layout
By default, the layout is set to horizontal but you can change it to vertical.
```javascript
unlayer.init({
tools: {
menu: {
properties: {
layout: {
value: 'vertical',
},
},
},
},
});
```
---
### Separator
You can add a separator between menu items by default.
```javascript
unlayer.init({
tools: {
menu: {
properties: {
separator: {
value: '|',
},
},
},
},
});
```
---
### Padding
```javascript
unlayer.init({
tools: {
menu: {
properties: {
padding: {
value: '10px',
},
},
},
},
});
```
---
## Built-In Tools
Our platform provides a variety of pre-configured, easy-to-use tools that empower your users to design and customize their content seamlessly. These built-in tools offer essential elements for creating emails, pages, and popups without the need for coding.
Each of the following tool is highly customizable, allowing you to adapt them to match your application’s design and user preferences.
---
## Paragraph
Paragraph is a built-in tool so users can add text to their designs. This tool is supported for all display modes.
## Enable / Disable
Paragraph tool is enabled by default but you can choose to disable it.
```javascript
unlayer.init({
tools: {
paragraph: {
enabled: false,
},
},
});
```
---
## Default Values
Paragraph tool comes with the following default values. You can choose to change any of these.
| Property | Default Value |
| :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| textJson | `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","text":"This is a new Paragraph block. Change the text.","type":"extended-text","version":1}],"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0}],"format":"","indent":0,"type":"root","version":1}}` |
| textAlign | `left` |
| fontSize | `14px` |
| color | `#000000` |
| lineHeight | `140%` |
| containerPadding | `10px` |
| fontFamily | `Arial, Helvetica, sans-serif` |
Here are a few examples on how to change these default values.
### Default Text
```javascript
unlayer.init({
tools: {
paragraph: {
properties: {
textJson: {
value:
'{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","text":"My Paragraph","type":"extended-text","version":1}],"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0}],"format":"","indent":0,"type":"root","version":1}}',
},
},
},
},
});
```
---
### Font Size
```javascript
unlayer.init({
tools: {
paragraph: {
properties: {
fontSize: {
editor: {
defaultValue: '16px',
},
},
},
},
},
});
```
---
### Alignment
```javascript
unlayer.init({
tools: {
paragraph: {
properties: {
textAlign: {
editor: {
defaultValue: 'right',
},
},
},
},
},
});
```
---
### Color
```javascript
unlayer.init({
tools: {
paragraph: {
properties: {
color: {
editor: {
defaultValue: '#ff0000',
},
},
},
},
},
});
```
---
### Line Height
```javascript
unlayer.init({
tools: {
paragraph: {
properties: {
lineHeight: {
editor: {
defaultValue: '140%',
},
},
},
},
},
});
```
---
### Container Padding
```javascript
unlayer.init({
tools: {
paragraph: {
properties: {
containerPadding: {
editor: {
defaultValue: '10px',
},
},
},
},
},
});
```
### Font Family
```javascript
unlayer.init({
tools: {
paragraph: {
properties: {
fontFamily: {
editor: {
defaultValue: {
value: "'Pacifico',cursive",
},
},
},
},
},
},
});
```
Please match exactly the value with one of those found in the [font family configuration](/builder/font-management/default-fonts).
---
## Migrating from Text Tool
If you're currently using the deprecated Text tool and need to maintain old functionality, you have two options:
#### Option 1: Duplicate the Text Tool
Find existing templates that use the Text tool and duplicate the tool:
1. Find existing templates that use the Text tool
2. Duplicate the Text tool
3. The duplicated Text tool will retain its old functionality
#### Option 2: Lock Your Builder Version
Pin your builder to a stable version where the Text tool is still available. See the [version locking documentation](/builder/latest/version-management) for detailed instructions.
---
## Social Media
Social Media is a built-in tool that lets users add their social media icons to an email or web page.
## Enable / Disable
```javascript
unlayer.init({
tools: {
social: {
enabled: false,
},
},
});
```
---
## Default Values
Social Media tool comes with the following default values. You can choose to change any of these.
| Property | Default Value |
| :--------------- | :------------ |
| icons | `[]` |
| spacing | `5` |
| align | `center` |
| containerPadding | `10px` |
Here are a few examples on how to change these default values.
### Icons
You can set the social media tool to come with default social networks and their URLs pre-filled.
```javascript
unlayer.init({
tools: {
social: {
properties: {
icons: {
value: {
iconType: 'squared',
icons: [
{ name: 'Facebook', url: 'https://facebook.com/' },
{ name: 'Twitter', url: 'https://twitter.com/' },
],
},
},
},
},
},
});
```
---
### Icon Spacing
```javascript
unlayer.init({
tools: {
social: {
properties: {
spacing: {
value: 5,
},
},
},
},
});
```
---
### Alignment
```javascript
unlayer.init({
tools: {
social: {
properties: {
align: {
value: 'center',
},
},
},
},
});
```
---
### Container Padding
```javascript
unlayer.init({
tools: {
social: {
properties: {
containerPadding: {
value: '10px',
},
},
},
},
});
```
---
## Custom Icons
Social media tool comes with a default set of social media networks. If you want to add a different social network, you can do that. Each custom icon must have an image url available for all icon types:
- circle
- circle-black
- circle-white
- rounded
- rounded-black
- squared
- squared-black
### Add New Icons
```javascript
unlayer.init({
tools: {
social: {
properties: {
icons: {
value: {
iconType: 'circle',
editor: {
data: {
customIcons: [
{
name: 'Twitch',
url: 'https://twitch.tv/',
icons: {
circle:
'https://cdn3.iconfinder.com/data/icons/popular-services-brands-vol-2/512/twitch-64.png',
'circle-black':
'https://cdn3.iconfinder.com/data/icons/popular-services-brands-vol-2/512/twitch-64.png',
'circle-white':
'https://cdn3.iconfinder.com/data/icons/popular-services-brands-vol-2/512/twitch-64.png',
rounded:
'https://cdn3.iconfinder.com/data/icons/popular-services-brands-vol-2/512/twitch-64.png',
'rounded-black':
'https://cdn3.iconfinder.com/data/icons/popular-services-brands-vol-2/512/twitch-64.png',
squared:
'https://cdn3.iconfinder.com/data/icons/popular-services-brands-vol-2/512/twitch-64.png',
'squared-black':
'https://cdn3.iconfinder.com/data/icons/popular-services-brands-vol-2/512/twitch-64.png',
},
},
],
},
},
},
},
},
},
},
});
```
### Turn Off Default
If you don't want the default icons to show and only show your custom ones, you can do that as well.
```javascript
unlayer.init({
tools: {
social: {
properties: {
icons: {
value: {
editor: {
data: {
showDefaultIcons: false,
customIcons: [],
},
},
},
},
},
},
},
});
```
---
## Table
Table is a built-in tool so users can add table to their designs. This tool is supported for all display modes.
## Enable / Disable
Table tool is enabled by default but you can choose to disable it.
```javascript
unlayer.init({
tools: {
table: {
enabled: false,
},
},
});
```
---
## Default Values
Table tool comes with the following default values. You can choose to change any of these.
| Property | Default Value |
| :------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| columns | `2` |
| rows | `2` |
| table | `{ headers: [{ cells: [{ text: 'Add header text' }, { text: '' }] }], rows: [{ cells: [{ text: 'Add text' }, { text: '' }] }, { cells: [{ text: '' }, { text: '' }] }] }` |
| border | `{ borderTopWidth: '1px', borderTopStyle: 'solid', borderTopColor: '#CCC', borderLeftWidth: '1px', borderLeftStyle: 'solid', borderLeftColor: '#CCC', borderRightWidth: '1px', borderRightStyle: 'solid', borderRightColor: '#CCC', borderBottomWidth: '1px', borderBottomStyle: 'solid', borderBottomColor: '#CCC' }` |
| stripedRows | `false` |
| stripedRowsBackgroundColor | `#CCC` |
| enableHeader | `true` |
| headerFontFamily | `inherit` |
| headerBackgroundColor | `#DDDDDD` |
| headerFontWeight | `700` |
| headerFontSize | `14px` |
| headerColor | `inherit` |
| headerTextAlign | `left` |
| headerPadding | `10px` |
| contentFontFamily | `inherit` |
| contentBackgroundColor | `#FFFFFF` |
| contentFontWeight | `inherit` |
| contentFontSize | `14px` |
| contentColor | `inherit` |
| contentTextAlign | `left` |
| contentLineHeight | `140%` |
| contentLetterSpacing | `inherit` |
| contentPadding | `10px` |
| enableFooter | `false` |
| footerFontFamily | `inherit` |
| footerBackgroundColor | `#DDDDDD` |
| footerFontWeight | `700` |
| footerFontSize | `14px` |
| footerColor | `inherit` |
| footerTextAlign | `left` |
| footerPadding | `10px` |
Here are examples of how to customize the default values for the table tool:
### Table Values
```javascript
unlayer.init({
tools: {
table: {
properties: {
table: {
value: {
headers: [
{
cells: [{ text: 'Name' }, { text: 'Email' }],
},
],
rows: [
{
cells: [{ text: 'John Doe' }, { text: 'john@example.com' }],
},
{
cells: [{ text: 'Jane Smith' }, { text: 'jane@example.com' }],
},
],
},
},
},
},
},
});
```
### Columns and Rows
```javascript
unlayer.init({
tools: {
table: {
properties: {
columns: {
editor: {
defaultValue: 4,
},
},
rows: {
editor: {
defaultValue: 5,
},
},
},
},
},
});
```
---
### Columns and Rows Limits
You can configure the minimum and maximum allowed values for columns and rows using `widgetParams`. This will enforce limits both in the property editor and in the table controls.
```javascript
unlayer.init({
tools: {
table: {
properties: {
columns: {
editor: {
widgetParams: {
minValue: 1,
maxValue: 4,
},
},
},
rows: {
editor: {
widgetParams: {
minValue: 1,
maxValue: 5,
},
},
},
},
},
},
});
```
---
### Background Color
```javascript
unlayer.init({
tools: {
table: {
properties: {
backgroundColor: {
editor: {
defaultValue: '#FFFFFF',
},
},
},
},
},
});
```
---
### Header Settings
```javascript
unlayer.init({
tools: {
table: {
properties: {
enableHeader: {
editor: {
defaultValue: true,
},
},
headerFontSize: {
editor: {
defaultValue: '16px',
},
},
headerColor: {
editor: {
defaultValue: '#333333',
},
},
headerFontWeight: {
editor: {
defaultValue: 600,
},
},
headerTextAlign: {
editor: {
defaultValue: 'center',
},
},
headerFontFamily: {
editor: {
defaultValue: {
value: "'Pacifico',cursive", // Please match exactly the value with one of those found in the font family configuration
},
},
},
},
},
},
});
```
---
### Content Settings
```javascript
unlayer.init({
tools: {
table: {
properties: {
contentFontSize: {
editor: {
defaultValue: '16px',
},
},
contentColor: {
editor: {
defaultValue: '#444444',
},
},
contentLineHeight: {
editor: {
defaultValue: '150%',
},
},
contentLetterSpacing: {
editor: {
defaultValue: '1px',
},
},
contentFontWeight: {
editor: {
defaultValue: 400,
},
},
contentTextAlign: {
editor: {
defaultValue: 'center',
},
},
contentFontFamily: {
editor: {
defaultValue: {
value: "'Pacifico',cursive", // Please match exactly the value with one of those found in the font family configuration
},
},
},
},
},
},
});
```
---
### Footer Settings
```javascript
unlayer.init({
tools: {
table: {
properties: {
enableFooter: {
editor: {
defaultValue: true,
},
},
footerFontSize: {
editor: {
defaultValue: '16px',
},
},
footerColor: {
editor: {
defaultValue: '#333333',
},
},
footerFontWeight: {
editor: {
defaultValue: 600,
},
},
footerTextAlign: {
editor: {
defaultValue: 'center',
},
},
footerBackgroundColor: {
editor: {
defaultValue: '#EEEEEE',
},
},
footerPadding: {
editor: {
defaultValue: '15px',
},
},
footerFontFamily: {
editor: {
defaultValue: {
value: "'Pacifico',cursive", // Please match exactly the value with one of those found in the font family configuration
},
},
},
},
},
},
});
```
---
### Striped Rows
```javascript
unlayer.init({
tools: {
table: {
properties: {
stripedRows: {
editor: {
defaultValue: true,
},
},
stripedRowsBackgroundColor: {
editor: {
defaultValue: '#F0F0F0',
},
},
},
},
},
});
```
---
### Border Settings
```javascript
unlayer.init({
tools: {
table: {
properties: {
border: {
editor: {
defaultValue: {
borderTopWidth: '2px',
borderTopStyle: 'solid',
borderTopColor: '#000000',
},
},
},
},
},
},
});
```
---
## Text
:::warning
Text tool was deprecated in favor of the [Paragraph tool](/builder/latest/tools/paragraph). Existing texts created with the Text tool will continue to work as expected.
:::
Text is a built-in tool so users can add text to their designs. This tool is supported for both display modes: emails and web pages.
## Enable / Disable
Text tool is enabled by default but you can choose to disable it.
```javascript
unlayer.init({
tools: {
text: {
enabled: false,
},
},
});
```
---
## Default Values
Text tool comes with the following default values. You can choose to change any of these.
| Property | Default Value |
| :--------------- | :----------------------------------------------------------------------------- |
| text | `This is a new Text block. Change the text.` |
| textAlign | `left` |
| fontSize | `16px` |
| color | `#000000` |
| lineHeight | `140%` |
| containerPadding | `10px` |
| fontFamily | `Arial, Helvetica, sans-serif` |
Here are a few examples on how to change these default values.
### Default Text
```javascript
unlayer.init({
tools: {
text: {
properties: {
text: {
value:
'This is a new Text block. Change the text.',
},
},
},
},
});
```
---
### Font Size
```javascript
unlayer.init({
tools: {
text: {
properties: {
fontSize: {
editor: {
defaultValue: '16px',
},
},
},
},
},
});
```
---
### Alignment
```javascript
unlayer.init({
tools: {
text: {
properties: {
textAlign: {
editor: {
defaultValue: 'right',
},
},
},
},
},
});
```
---
### Color
```javascript
unlayer.init({
tools: {
text: {
properties: {
color: {
editor: {
defaultValue: '#000000',
},
},
},
},
},
});
```
---
### Line Height
```javascript
unlayer.init({
tools: {
text: {
properties: {
lineHeight: {
editor: {
defaultValue: '140%',
},
},
},
},
},
});
```
---
### Container Padding
```javascript
unlayer.init({
tools: {
text: {
properties: {
containerPadding: {
editor: {
defaultValue: '10px',
},
},
},
},
},
});
```
### Font Family
```javascript
unlayer.init({
tools: {
text: {
properties: {
fontFamily: {
editor: {
defaultValue: {
value: "'Pacifico',cursive",
},
},
},
},
},
},
});
```
Please match exactly the value with one of those found in the font family configuration.
---
## Timer
Timer is a built-in tool used to create countdown timers. This tool create a countdown GIF image to run a realtime countdown. It is supported in both display modes: email and web pages.
## Enable / Disable
Timer tool is a premium tool. If you have it in your subscription, It's enabled by default but you can choose to disable it.
```javascript
unlayer.init({
tools: {
timer: {
enabled: false,
},
},
});
```
---
## Default Values
Timer tool comes with the following default values. You can choose to change any of these.
| Property | Default Value |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| countdown | `{ countdownUrl: "https://cdn.tools.unlayer.com/countdown/countdown.gif", endTime: "", backgroundColor: "#FFFFFF", labelColor: "#A3A3A3", labelFontFamily: "Open Sans", labelFontSize: 16, digitColor: "#404040", digitFontFamily: "Open Sans", digitFontSize: 40, timezone: "America/Los_Angeles", locale: null, showLabels: true }` |
| width | `{ autoWidth: true, width: "100%" }` |
| textAlign | `center` |
| altText | `Countdown` |
| action | `{ name: "web", values: { href: "", target: "_blank" } }` |
Here are a few examples on how to change these default values.
### Countdown Properties
You can change any of the default countdown properties such as labels, language, fonts, timezone, etc.
Each property expects a specific type. Only `locale` accepts `null`; for every other property,
omit it to keep its default rather than passing `null` (an omitted property always falls back to its
built-in default).
| Property | Type | Notes |
| ----------------- | ---------------- | --------------------------------------------------- |
| `countdownUrl` | string | Placeholder GIF shown before the timer is rendered |
| `endTime` | string | ISO date-time, e.g. `2030-01-01T00:00:00` |
| `backgroundColor` | string | Hex or rgb(a) color |
| `labelColor` | string | Hex or rgb(a) color |
| `digitColor` | string | Hex or rgb(a) color |
| `labelFontFamily` | string | One of the supported fonts below (case-insensitive) |
| `digitFontFamily` | string | One of the supported fonts below (case-insensitive) |
| `labelFontSize` | number | |
| `digitFontSize` | number | |
| `timezone` | string | IANA timezone, e.g. `America/Los_Angeles` |
| `locale` | string \| `null` | `null` uses the editor's locale |
| `showLabels` | boolean | |
`labelFontFamily` and `digitFontFamily` accept one of the following names (matched
case-insensitively, so `arial` resolves to `Arial`): `Arial`, `Arial Black`, `Book Antiqua`,
`Comic Sans MS`, `Courier New`, `Georgia`, `Helvetica`, `Impact`, `Open Sans`, `Times New Roman`.
An unrecognized name falls back to `Open Sans`.
```javascript
unlayer.init({
tools: {
timer: {
properties: {
countdown: {
value: {
countdownUrl: `https://cdn.tools.unlayer.com/countdown/countdown.gif`,
endTime: '',
backgroundColor: '#000000',
labelColor: '#FFFFFF',
labelFontFamily: 'Open Sans',
labelFontSize: 28,
digitColor: '#FFFFFF',
digitFontFamily: 'Open Sans',
digitFontSize: 75,
timezone: 'America/Los_Angeles',
locale: null,
showLabels: false,
},
},
},
},
},
});
```
---
### Layout
By default, the layout is set to horizontal but you can change it to vertical.
```javascript
unlayer.init({
tools: {
timer: {
properties: {
layout: {
value: 'vertical',
},
},
},
},
});
```
---
### Width
By default, countdown GIF image is added with auto width turned on. You can change it.
```javascript
unlayer.init({
tools: {
timer: {
properties: {
width: {
value: {
autoWidth: false,
width: '50%',
},
},
},
},
},
});
```
---
### Alternate Text
```javascript
unlayer.init({
tools: {
timer: {
properties: {
altText: {
value: 'Countdown Timer',
},
},
},
},
});
```
---
## Video
Video is a built-in tool that lets users add **YouTube** or **Vimeo** videos to their emails or landing pages. This tool automatically generates thumbnails of videos for emails and ensures that it renders correctly on all platforms.
## Enable / Disable
```javascript
unlayer.init({
tools: {
video: {
enabled: false,
},
},
});
```
---
## CSS / JavaScript
Each tool has the ability to insert it's CSS or JavaScript in the `` part of HTML. This is useful if you don't want to use inline styles and want the CSS to be in the head instead.
You can target the specific instance of the tool by its HTML id using **values.\_meta.htmlID**.
```javascript
unlayer.registerTool({
name: 'my_tool',
label: 'My Tool',
icon: 'fa-smile',
supportedDisplayModes: ['web', 'email'],
options: {
colors: {
title: 'Colors',
position: 1,
options: {
textColor: {
label: 'Text Color',
defaultValue: '#FF0000',
widget: 'color_picker',
},
backgroundColor: {
label: 'Background Color',
defaultValue: '#FF0000',
widget: 'color_picker',
},
},
},
},
values: {},
renderer: {
Viewer: unlayer.createViewer({
render(values) {
return `I am a custom tool.`;
},
}),
exporters: {
web: function (values) {
return `I am a custom tool.`;
},
email: function (values) {
return `I am a custom tool.`;
},
},
head: {
css: function (values) {
return `#${values._meta.htmlID} div { background-color: ${values.backgroundColor}; color: ${values.textColor}; }`;
},
js: function (values) {
return `console.log("Tool JavaScript");`;
},
},
},
});
```
---
## Property States
When you register a tool, you can also provide a function to manage property states. This is especially useful if you want to hide or show property editors when value of another property changes.
```javascript
unlayer.registerTool({
...
propertyStates: (values) => {
if (values.textAlign === 'center') {
return {
textColor: {
enabled: false
}
};
}
}
);
```
In the example above, we are hiding the `textColor` color picker when value of `textAlign` is set to `center`.
---
## Transform Property Values
When you register a tool, you can also create a transformer for that tool. Transformer is a function that is called each time any of the properties is changed and gives you an opportunity to modify any or all of the values in the tool.
```javascript
unlayer.registerTool({
...
transformer: (values, source) => {
const { name, value, data } = source;
// Transform the values here
let newValues = {
...values
};
// Return updated values
return newValues;
}
});
```
The arguments passed to the transformer are these:
| Option | Description |
| :--------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **values** | Current property values for the tool |
| **source** | An object that contains information for the property that triggered this call:**name** of the property**value** of the property**data** is the optional metadata passed by the [updateValue](../create-custom-tool.md#javascript) function of the property editor |
---
## Built-In Property Editors
Following is a list of built-in property editors available and their value formats.
## Text Input

#### Widget
```
text
```
#### Default Value
```
String
```
---
## Rich Text Input V2
:::warning WARNING
`rich_text` was deprecated. Use `rich_text_v2` instead for new custom tools. It's a direct replacement with the same HTML string format.
:::

#### Widget
```
rich_text_v2
```
#### Default Value
```
String
```
---
## HTML Input

#### Widget
```
html
```
#### Default Value
```
String
```
---
## Color Picker

#### Widget
```
color_picker
```
#### Default Value
```
String
```
#### Valid Values
- HEX Format `#FFF000`
- RGB Format `rgba(0,0,0,0.5)`
---
## Text Alignment

#### Widget
```
alignment
```
#### Default Value
```
String
```
#### Valid Values
- `left`
- `center`
- `right`
---
## Font Family

#### Widget
```
font_family
```
#### Default Value
```
JSON
```
#### Valid Value
```json
{
"label": "Arial",
"value": "arial,helvetica,sans-serif"
}
```
---
## Border

#### Widget
```
border
```
#### Default Value
```
JSON
```
#### Valid Value
```json
{
"borderTopWidth": "0px",
"borderTopStyle": "solid",
"borderTopColor": "#CCC",
"borderLeftWidth": "0px",
"borderLeftStyle": "solid",
"borderLeftColor": "#CCC",
"borderRightWidth": "0px",
"borderRightStyle": "solid",
"borderRightColor": "#CCC",
"borderBottomWidth": "0px",
"borderBottomStyle": "solid",
"borderBottomColor": "#CCC"
}
```
---
## Counter

#### Widget
```
counter
```
#### Default Value
```
String
```
#### Example Values
`10` `25` `50` etc
---
## Image Uploader

#### Widget
```
image
```
#### Default Value
```
JSON
```
#### Valid Value
```json
{
"url": "http://via.placeholder.com/350x150"
}
```
---
## Toggle

#### Widget
```
toggle
```
#### Default Value
```
Boolean
```
#### Valid Values
- `true`
- `false`
---
## Link

#### Widget
```
link
```
#### Default Value
```
JSON
```
#### Valid Values
```json
{
"name": "web",
"values": {
"href": "http://google.com",
"target": "_blank"
}
}
```
#### Return Value
```json
{
"url": "http://google.com",
"target": "_blank"
}
```
---
## Dropdown

#### Widget
```
dropdown
```
### Dropdown Options
The dropdown editor needs options to be passed as data. Learn more at: [Passing Data to Property Editors](./create-custom-tool.md#dropdown-example)
---
## Date / Time

#### Widget
```
datetime
```
#### Default Value
```
String
```
#### Widget Params
```
hideTime (boolean)
hideCalendar (boolean)
format (string)
```
---
## Custom Editors
If you want to build a custom property editor for advanced use cases, you can do that with our [registerPropertyEditor API](./create-custom-tool.md#custom-property-editor).
---
## Create a Custom Tool
This tutorial will get you started with your first custom tool using our JavaScript API.
Before you proceed, please make sure you have read the overview and understand the following:
- [**Renderer**](./overview.md#renderer)
- [**Properties**](./overview.md#properties)
:::info Custom JavaScript
The following JavaScript has to be passed in the `customJS` parameter when initializing Unlayer. **registerTool**, **registerPropertyEditor**, **createViewer** and **createWidget** methods are only available there.
[Learn More](../../customization/appearance/custom-js-css.md#custom-javascript)
:::
## Register your Tool
The first step is to register your custom tool using the `registerTool` method.

```javascript
unlayer.registerTool({
name: 'my_tool',
label: 'My Tool',
icon: 'fa-smile',
supportedDisplayModes: ['web', 'email'],
options: {},
values: {},
renderer: {
Viewer: unlayer.createViewer({
render(values) {
return 'I am a custom tool.';
},
}),
exporters: {
web: function (values) {
return 'I am a custom tool.';
},
email: function (values) {
return 'I am a custom tool.';
},
},
head: {
css: function (values) {},
js: function (values) {},
},
},
validator(data) {
return [];
},
});
```
:::info Plan Limits
The number of custom tools you can register is governed by your plan's Custom Tools
limit. Calling `registerTool` beyond this limit will not make the extra tools
available — they are excluded from the tools panel and a warning is logged to the
browser console. If a design references an over-limit custom tool, the editor renders
a "Not available on your plan" placeholder in its place (with an upgrade link when
applicable) and exports nothing for it. Custom tools within your plan limit are
unaffected. To register more custom tools, upgrade your plan.
:::
It needs some configuration options which are explained below:
| Option | Description |
| :------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **name** | Unique name for your tool |
| **label** | Label that users see in the tools panel |
| **icon** | Icon that users see in the tools panel. The value can be class name of any [fontawesome icon](https://fontawesome.com/icons?d=gallery), or a URL for your icon's image |
| **supportedDisplayModes** | Which display modes will this tool work in. Default value is `['email', 'web']` |
| **usageLimit** | Optional. You can set a limit to the number of times this tool can be used in a design |
| **options** | This is where we define tool properties and property editors |
| **values** | Initial values for our tool's properties |
| **renderer** | This is where we will add a `Viewer` , `exporters` and `head` |
| **supportedByAI** | Optional. Set to `false` to hide this tool from the AI Assistant |
| **validator** | You can add a validator function to check for issues in the values. For example, you can check values of properties to make sure everything looks correct. These errors/warnings would show up in the Audit tab. [Learn More](../../customization/content-settings/content-audit.md) |
:::info React: Recommended Usage
Unlayer exposes React 19 globally as `unlayer.React` and `unlayer.ReactDOM`. Reusing these globals avoids loading React twice and helps prevent version conflicts. Check our [React Example](https://examples.unlayer.com/custom_tools/react-custom-tool).
For synchronous rendering, create a root, wrap the render in `flushSync`, and read the HTML before unmounting:
```js
const el = document.createElement('div');
const root = unlayer.ReactDOM.createRoot(el);
unlayer.ReactDOM.flushSync(() => {
root.render(element);
});
const html = el.innerHTML;
root.unmount();
return html;
```
`unlayer.ReactDOM.render(element, container)` is still available as a deprecated compatibility shim for existing custom tools, but new code should target React 19+.
:::
---
## Add Tool Properties
Every tool needs certain properties to create content. Properties are variables that the user can modify, such as colors, font sizes, text alignment, images, and more.
Each property is assigned a _property editor_ which is a **UI control widget** used by the user to modify the property value. Check out the list of [built-in property editors](./built-in-property-editors.md).
## Color Picker Example
In this example, we will add 2 properties to our tool under a group called "**Colors**":
1. **Text Color** `textColor`
2. **Background Color** `backgroundColor`

Let's define the property group and both the properties inside it. This will be done in the `options` object. Each property group should have a unique key in options so we will call this group `colors`.
We will name the properties `textColor` and `backgroundColor` and use the [built-in](./built-in-property-editors.md#color-picker) `color_picker` editor widget for both of them. We will give each property a default value of `#FF0000`.
For the Renderer, we will use the `textColor` and `backgroundColor` in the HTML to apply those colors to our template. These properties will be available in the `values` object passed to the Viewer and the exporters.
```javascript
unlayer.registerTool({
name: 'my_tool',
label: 'My Tool',
icon: 'fa-smile',
supportedDisplayModes: ['web', 'email'],
options: {
colors: {
// Property Group
title: 'Colors', // Title for Property Group
position: 1, // Position of Property Group
collapsed: false, // Initial collapse state
options: {
textColor: {
// Property: textColor
label: 'Text Color', // Label for Property
defaultValue: '#FF0000',
widget: 'color_picker', // Property Editor Widget: color_picker
},
backgroundColor: {
// Property: backgroundColor
label: 'Background Color', // Label for Property
defaultValue: '#FF0000',
widget: 'color_picker', // Property Editor Widget: color_picker
},
},
},
},
values: {},
renderer: {
Viewer: unlayer.createViewer({
render(values) {
return `I am a custom tool.`;
},
}),
exporters: {
web: function (values) {
return `I am a custom tool.`;
},
email: function (values) {
return `I am a custom tool.`;
},
},
head: {
css: function (values) {},
js: function (values) {},
},
},
validator(data) {
const { defaultErrors, values } = data;
return [];
},
});
```
### Property Group
Property group is a collapsable panel that is used to organize properties into a group. This makes the experience of editing a template simpler and easier.
The `options` object defines all the property groups and properties available for your custom tool. Here's a breakdown of the available options:
| Option | Description |
| :------------ | :---------------------------------------------------------------- |
| **title** | Title for the property group shown in the panel |
| **position** | Position order of the property group (lower numbers appear first) |
| **collapsed** | This boolean controls the initial collapse state of the group |
| **options** | Contains all properties within this group |
---
## Custom Property Editor
In the example above, we used a built-in `color_picker` property editor. We have many [built-in property editors](./built-in-property-editors.md) available but many times you may want to use a custom property editor that is tightly integrated with your application.
Here's a small example where we add a custom color picker property editor. We'll call it `my_color_picker`. We will use the `registerPropertyEditor` method to register it.

```javascript
unlayer.registerPropertyEditor({
name: 'my_color_picker',
Widget: unlayer.createWidget({
// JSON Schema for Unlayer's AI Assistant support
schema: {
type: 'string',
description: 'Hex color value (e.g. #ff0000)',
},
render(value, updateValue, data) {
return `
`;
},
mount(node, value, updateValue, data) {
var input = node.getElementsByClassName('color-value')[0];
input.onchange = function (event) {
updateValue(event.target.value);
};
var redButton = node.getElementsByClassName('red')[0];
redButton.onclick = function () {
updateValue('#f00');
};
var greenButton = node.getElementsByClassName('green')[0];
greenButton.onclick = function () {
updateValue('#0f0');
};
var blueButton = node.getElementsByClassName('blue')[0];
blueButton.onclick = function () {
updateValue('#00f');
};
},
}),
});
```
Once our new property editor is registered, you can use it just like the built-in property editors when registering your tool. The configuration options are explained below:
| Option | Description |
| :--------- | :-------------------------------------------------------------------------------------------------------------------------------------------- |
| **name** | Unique slug for your property editor widget. You will have to provide this in `options` as a `widget` when registering your tool. |
| **Widget** | This is the UI widget. If you are using React, this would be a `Component`. If you are using vanilla JavaScript, you will use `createWidget`. |
## Widget
For each property editor, you have to pass a `Widget`. If you are using React, this would be a `Component`. If you are using vanilla JavaScript, you will use the `createWidget` function.
### **JavaScript**
The `unlayer.createWidget` function would be passed an object with the following attributes. You can check the [full example](https://examples.unlayer.com/custom_tools/simple-custom-tool#customtoolwithcustompropertyeditor).
| Attribute | Description |
| :--------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **render** | This is the HTML of your widget. It is passed the following arguments:**value** is the current value of this property**updateValue** is the function to update the value of this property. The first argument has to be the new value, and the second argument is optional data that can be used in the [transformer](./advanced-options/transform-property-values.md) to update values for other properties.**data** is the optional data passed by your application |
| **mount** | This gets called when your widget is mounted. You can attach events to the HTML elements here. It is passed the following arguments:**node** is the DOM ref to the HTML element that you created in the render method.**value** is the current value of this property**updateValue** is the function to update the value of this property. The first argument has to be the new value, and the second argument is optional data that can be used in the [transformer](./advanced-options/transform-property-values.md) to update values for other properties.**data** is the optional data passed by your application |
| **schema** | A [JSON Schema](https://json-schema.org/) object describing the value shape produced by this widget. Used by Unlayer's AI Assistant to understand what fields your custom property editor supports, so it can generate content for your custom tool. |
### **React**
If you are using React, you can create the same widget as a `Component`. You can check the full example [here](https://examples.unlayer.com/custom_tools/react-custom-tool).
```javascript
const MyColorPicker = (props) => {
const { label, value, updateValue, data } = props;
return (
updateValue(e.target.value)}
/>
);
};
unlayer.registerPropertyEditor({
name: 'my_color_picker',
Widget: MyColorPicker,
});
```
:::info React: Recommended Usage
Unlayer exposes React 19 globally as `unlayer.React` and `unlayer.ReactDOM`. Reusing these globals avoids loading React twice and helps prevent version conflicts. Check our [React Example](https://examples.unlayer.com/custom_tools/react-custom-tool).
For synchronous rendering, create a root, wrap the render in `flushSync`, and read the HTML before unmounting:
```js
const el = document.createElement('div');
const root = unlayer.ReactDOM.createRoot(el);
unlayer.ReactDOM.flushSync(() => {
root.render(element);
});
const html = el.innerHTML;
root.unmount();
return html;
```
`unlayer.ReactDOM.render(element, container)` is still available as a deprecated compatibility shim for existing custom tools, but new code should target React 19+.
:::
---
## Pass Data to Custom Tools
In some cases, your custom tool will need data from your application instance. You can pass that data when the Unlayer editor is initialized. Inside the `tools` object, you can add the data that your tool needs.
In this example, we will pass user's information. This data will be available in the `values` parameter to your tool's renderer and exporters. If your tool's name is `my_tool`, you will add an object called `custom#my_tool` and pass the `data` object in it. Replace `NAME` with the name of your tool.
You can see a full running example here: [Live Demo](https://examples.unlayer.com/custom_tools/custom-tool-data)
```javascript
unlayer.init({
...
tools: {
'custom#NAME': {
data: {
name: 'John Doe',
age: '27',
photo: 'http://via.placeholder.com/350x150'
}
}
}
});
```
---
## Pass Data to Property Editors
Some property editors need data from your application programmatically. You can pass this data during the initialization.
Replace `NAME` with your tool's `name` and `PROPERTY-NAME` with the property's `name`. For example, if your tool's name is `my_color_picker` and property name is `colorOptions`, you will use `custom#my_color_picker` as the key and `colorOptions` as the property name.
You can see a full running example here: [Live Demo](https://examples.unlayer.com/custom_tools/custom-tool-data)
```javascript
unlayer.init({
...
tools: {
'custom#NAME': {
properties: {
PROPERTY-NAME: {
editor: {
data: {
"hello": "world"
}
}
}
}
}
}
});
```
### **Dropdown Example**
Let's say your custom tool has a property that needs to use our built-in dropdown property editor. You can use the following example to pass options to the dropdown property editor.
In this example, our tool's name is `contact_form` and our property's name is `department` which is using a dropdown editor. We want the user to pick Sales, Customer Support, HR or Other from the dropdown.
You can see a full running example here: [Live Demo](https://examples.unlayer.com/custom_tools/custom-tool-data)
```javascript
unlayer.init({
...
tools: {
'custom#contact_form': {
properties: {
department: {
editor: {
data: {
options: [
{label: "Custom Support", value: "Customer Support"},
{label: "Sales", value: "Sales"},
{label: "HR", value: "HR"},
{label: "Other", value: "Other"}
]
}
}
}
}
}
}
});
```
---
## Full Example
We have a full example of custom tools and custom property editors with the JavaScript API in our playgrounds. [Check it out here](https://examples.unlayer.com/custom_tools/simple-custom-tool).
---
## Custom Tools
## What is a Custom Tool?
Custom tools are user-defined elements that extend the default functionality of the visual builder, allowing developers to create unique design components tailored to their specific needs. Unlike built-in tools, custom tools offer complete flexibility in terms of appearance, behavior, and interactivity. Developers can define how these tools behave, including custom properties, actions, and styling options.
Custom tools integrate seamlessly into the editor’s interface, enabling users to drag, drop, and configure them just like built-in tools. This allows for advanced customization, supporting a wide range of use cases from specialized content elements to custom workflows. We know that every application is different and needs different tools to reach its full potential.

Every custom tool needs to have the following attributes to get running.
| Attribute | Description |
| :-------------------------- | :------------------------------------------------------------------ |
| **Name** | A unique identifier string for your tool |
| **Label** | A display label for your tool that the users will see |
| **Icon** | An icon for your tool from a choice of 700+ icons or a custom image |
| **Supported Display Modes** | Display mode this tool will work in (**email**, **web** or both) |
| **Usage Limit** | Number of times a tool can be used in a single design |
| **Renderer** | Renderer contains the content that the tool will create and export |
| **Properties** | A set of variables that your tool will need to create content |
---
## **Renderer**
Renderer is where you define the content that your tool will create. This is done using a HTML template. Each renderer consists of a Viewer, Exporters and Head. In most cases, Viewer and Exporters contain the same HTML template but there are cases when these could be different.
### Viewer
Viewer is the tool's template which will be visible to the user when they use your tool within the Unlayer editor.
### Exporters
Exporters define the tool's HTML template which will be passed to your application when you call [exportHtml](../../get-started/export/export-html.md). Depending on your supported display modes, each tool can have these 2 exporters:
- Email
- Web
This reflects what the output should be for each of the 2 display modes. HTML markup for email can be very different from a web page so this is where you can have the same tool working nicely for both display modes.
### Head
Head is where you define any CSS or JavaScript that the tool will need to function. As the name says, these assets will be added to the `` part of HTML. [Learn more](./advanced-options/css-javascript.md)
---
## Properties
Every tool needs certain properties to create content. Properties are variables that the user can modify, such as colors, font sizes, text alignment, images, and more.
### Property Group
Property group is a collapsable panel that is used to organize properties into a group. This makes the experience of editing a template simpler and easier.

### Property Editor
Each property is assigned a property editor which is a **UI control widget** used by the user to modify the property value. For example: color picker, slider, toggle, etc. We have many [built-in property editors](./built-in-property-editors.md) available, and you can also create a [custom property editor](./create-custom-tool.md#custom-property-editor).

---
## Tools
Tools are the core building blocks within the drag-and-drop builder that allow users to create and customize content. Each tool represents an individual design element, such as text, buttons, images, or headings, that can be easily added to an email, page, or popup. Tools provide essential functionality and design capabilities, enabling users to visually construct their content without writing code.
These tools are pre-configured with default settings but offer a range of customization options. Users can modify their appearance, behavior, and interactions to suit specific design requirements and business needs. Additionally, developers can extend these tools by creating [custom tools](./custom-tools/overview.md), giving even more flexibility to tailor the design experience.
## Enable / Disable Tools
You can choose which tools to enable or disable for the Unlayer editor instance.
Here's some examples...
### Disable Image Tool
```javascript
unlayer.init({
tools: {
image: {
enabled: false,
},
},
});
```
---
## Reposition Tools
You can reposition tools to appear before or after other tools.
```javascript
unlayer.init({
tools: {
image: {
position: 1,
},
},
});
```
---
## Usage Limit
You can specify a limit to how many times a tool can be used in a single design. For example, if you want the form to be used only once in a design, you can do this:
```javascript
unlayer.init({
tools: {
form: {
usageLimit: 1,
},
},
});
```
---
## Change Icon
You can change the icon of any tool. For example, if you want to change the icon of image tool, you can do this:
```javascript
unlayer.init({
tools: {
image: {
icon: '[IMAGE URL HERE]',
},
},
});
```
---
## Built-In Tools
Our platform provides a variety of pre-configured, easy-to-use tools that empower your users to design and customize their content seamlessly. These built-in tools offer essential elements for creating emails, pages, and popups without the need for coding. Each tool is highly customizable, allowing you to adapt them to match your application’s design and user preferences. [Learn More](./built-in-tools/overview.md)
---
## Custom Tools
A custom tool can help you add more content elements to the editor. [Learn more](./custom-tools/overview.md) on how to build a custom tool.

---
## Custom JS / CSS
The builder runs inside an iframe, which allows developers to customize its appearance and behavior using custom JavaScript and CSS. You can pass both custom JS and CSS during the builder's initialization. This feature is particularly useful for adjusting the builder's look and feel or extending its functionality with custom tools and custom property editors.
---
## Custom CSS
Custom CSS allows you to make design tweaks to the builder. It's useful for branding, adjusting layouts, and styling the builder interface to fit your application's design language.
:::info String or Array
`customCSS` accepts a string or an array of strings
:::
To add custom CSS to the builder, pass a CSS string or URL to the customCSS option when initializing the builder.
```javascript
unlayer.init({
customCSS: 'https://example.com/custom-styles.css',
});
```
Or, you can pass the CSS source code like this:
```javascript
unlayer.init({
customCSS: [
"
body {
background-color: yellow;
}
"
]
});
```
### Use Cases for Custom CSS
- **Branding**: Customize colors, fonts, and layouts to match your application.
- **Appearance Tweaks**: Adjust margins, paddings, and sizes to fit your design.
- **Custom Tool Styling**: Style custom tools or custom property editors for consistency with your app's UI.
---
## Custom JavaScript
Custom JavaScript allows developers to extend the functionality of the builder by creating custom tools, custom property editors, or handling additional events inside the iframe. Unlayer's SDK supports advanced methods like `unlayer.registerTool()` and `unlayer.registerPropertyEditor()` to enable these custom features. These methods are only available inside the iframe.
:::info String or Array
`customJS` accepts a string or an array of strings
:::
You can pass custom JavaScript to the builder using the `customJS` property during initialization. The custom JavaScript will run inside the iframe, allowing you to register new tools, modify existing ones, or add new property editors.
```javascript
unlayer.init({
customJS: '//cdn.muicss.com/mui-0.9.28/js/mui.min.js',
});
```
Or, you can pass the JS source code like this:
```javascript
unlayer.init({
customJS: [
"
console.log('I am custom JS!');
"
]
});
```
### Key Methods for Custom JavaScript
- **unlayer.registerTool()**
This method is used to create custom tools that appear in the builder's toolbox. You can define the behavior, properties, and UI of the custom tool. [Learn More](../../tools/custom-tools/overview.md)
- **unlayer.registerPropertyEditor()**
This method allows you to create custom property editors for tools. Custom property editors allow users to input data and modify the behavior or content of a tool. [Learn More](../../tools/custom-tools/overview.md)
---
### Use Cases for Custom JavaScript
- **Custom Tools**: Build tools tailored to your end-users, such as special forms, custom buttons, or complex layout elements.
- **Property Editors**: Add custom input fields or property editors to allow users to customize the behavior and appearance of the custom tools.
- **Event Handling**: Handle custom events or extend the functionality of existing tools by reacting to actions taken by the end-users.
---
## Examples
You can see a few examples of Custom JS and Custom CSS in the [Examples & Playground](https://examples.unlayer.com).
---
## Layout
The layout options allow developers to configure the layout of the builder's tool panels, ensuring that the user interface can be customized to fit the needs and preferences of your end-users.
These options give you control over where the tool panel is positioned and whether it can be collapsed, providing flexibility in how the builder interface is structured.
## Tools Position
By default, the tool panel is positioned on the right side of the builder. However, you can change the position of the panel to better fit your layout needs.
### Setting Panel Position
You can control the position of the tool panel using the dock option. The tool panel can be docked either on the right or the left side of the builder interface.
#### Docking Options
- **right** (default)
- **left**
Example of docking the panel on the right (default):
```javascript
unlayer.init({
appearance: {
panels: {
tools: {
dock: 'right',
},
},
},
});
```
To dock the panel on the left:
```javascript
unlayer.init({
appearance: {
panels: {
tools: {
dock: 'left',
},
},
},
});
```
## Collapsible Panel
This feature allows you to enable or disable the ability to collapse the tool panel. By default, the tool panel is collapsible, meaning users can hide or show the panel as needed to create more space while designing.
### Enable or Disable Collapsing
You can control whether the tool panel is collapsible using the collapsible option. When set to`false`, the tool panel will remain fixed and cannot be collapsed.
Example of disabling collapsibility:
```javascript
unlayer.init({
appearance: {
panels: {
tools: {
collapsible: false,
},
},
},
});
```
---
## Best Practices
- **User Flexibility**: Consider keeping the collapsible feature enabled (collapsible: true) to allow users to create more space while designing, especially on smaller screens.
- **Docking Position**: Position the tool panel on the side that best fits your application's layout and design. The default right-side docking works well for most users, but left-docking may be preferable depending on the design flow or user preferences.
- **Responsive Design**: When positioning the panel or making it collapsible, ensure that the layout works well across different devices.
By customizing the layout options, developers can create a more user-friendly and adaptable interface for the builder, providing flexibility in how the tool panel is displayed and used.
---
## Loading Spinner
This feature allows you to replace the default loading spinner in the builder with a custom one, either using an animated image or an HTML/CSS-based loader. This gives you flexibility to match the builder’s loading animation with your application’s branding or user interface design.
### Animated Image
You can replace the default loader with a custom animated image (such as a GIF or PNG). This is useful if you have a branded or pre-designed loading spinner image.
```javascript
unlayer.init({
appearance: {
loader: {
url: 'https://example.com/loader.gif', // URL of the custom loading spinner image
},
},
});
```
In this example, the loading spinner will display the image from the provided URL whenever the builder is loading.
---
### CSS Loader
If you prefer to create a fully customized loader using HTML and CSS, you can provide the structure and styling for the loader directly in the configuration. This approach gives you full control over the design and animations of the loading spinner.
```javascript
unlayer.init({
appearance: {
loader: {
html: '', // HTML structure for the loader
css: '.custom-spinner { color: red; }', // CSS styling for the loader
},
},
});
```
In this example, the custom loader will display a red spinner as defined by the HTML and CSS. You can create complex loaders using animations or any design that fits your needs.
---
## Best Practices
- **Branding**: Use a custom loader that aligns with your brand’s identity. Whether using an animated image or custom HTML/CSS, ensure that the design is consistent with your application’s overall look and feel.
- **Performance**: When using an animated image, ensure that the file size is optimized to avoid slow loading times. For HTML/CSS loaders, avoid overly complex animations that could impact performance.
- **Testing Across Devices**: If you are using a CSS loader, test the design across different devices and screen sizes to ensure it behaves and renders correctly, especially on mobile and tablets.
---
## Appearance
This section allows developers to configure how the builder itself looks and feels. This section focuses on the visual presentation and layout of the builder interface, providing customization options to match your brand or enhance the user experience.
In this section, you can:
- **Themes & Layouts**: Choose themes and layouts for the builder interface, allowing you to adjust its overall appearance to suit your application’s design style.
- **Custom CSS/JS**: Add custom styles or scripts to further tailor the builder’s appearance, ensuring it aligns with your brand’s look and feel.
- **Design Tweaks**: Make small adjustments to the builder’s UI elements, enhancing the user interface to provide a smoother editing experience.
By configuring the builder’s appearance, you can provide a visually cohesive and user-friendly environment for your end-users, ensuring that the design and layout of the builder are consistent with your platform’s branding and usability standards.
---
## Themes
This feature allows you to customize the overall look and feel of the drag-and-drop builder interface. We offer four out-of-the-box themes that can be easily applied to the builder, giving you the flexibility to choose between light and dark modes as well as different design styles. You can also pass a custom theme object to override the editor colors with your own.
## Available Themes
### **Modern Light** – _Default_
#### `modern_light`
A more contemporary design with light tones, providing a fresh and minimalist feel while maintaining clarity and usability. This is the default theme.
### **Modern Dark** {#dark-mode}
#### `modern_dark`
A darker, modern interface that combines a sleek design with a minimalistic feel, ideal for users who prefer dark mode with a modern touch.
### **Classic Light**
#### `classic_light`
A clean, traditional interface with a light background, designed for clarity and simplicity.
### **Classic Dark**
#### `classic_dark`
A darker version of the classic theme, ideal for users who prefer a low-light interface or want a more modern, sleek look.
---
## Setting a Theme
You can configure the theme for the builder during initialization using the `unlayer.init()` method. By default, the builder is set to the Classic (Light) theme, but you can easily switch to any of the available themes.
Example of setting a theme:
```javascript
unlayer.init({
appearance: {
theme: 'modern_light',
},
});
```
Or you can change the theme on the fly like this:
```javascript
unlayer.setTheme('modern_dark');
```
#### Built-in Theme Options
- modern_light (default)
- modern_dark
- classic_light
- classic_dark
---
## Custom Themes
Instead of passing a string with the theme name, you can pass an object overriding the editor colors. Here's how you create a custom Modern Dark Purple theme:
```js
unlayer.init({
appearance: {
theme: {
name: 'custom_modern_dark_purple',
extends: 'modern_dark',
isClassic: false,
isDark: true,
mapping: {
borderRadius: {
none: '0',
min: '2px',
mid: '8px',
max: '12px',
full: '50%',
},
colors: {
accent_01: '#9632a0',
accent_02: '#ae70b4',
accent_03: '#bf87c4',
accent_04: '#d9b4dc',
accent_05: '#eed5f0',
primary_01: '#ffffff',
primary_02: '#f2ebf4',
primary_03: '#e8dded',
primary_04: '#d2c2d6',
primary_05: '#9c95a6',
primary_06: '#906994',
primary_07: '#5d425c',
primary_08: '#4f344d',
primary_09: '#3c263d',
primary_10: '#4e2e51',
primary_11: '#441e42',
},
},
},
},
});
```
Or you can change the theme on the fly like this:
```js
unlayer.setTheme(myCustomThemeObject);
```
### Custom JS / CSS
If the out-of-the-box themes don't fully meet your needs, or you want to customize some very specific UI element, you can use the [Custom JS / CSS](./custom-js-css.md) options. This gives you further control to tweak the existing themes or create a completely new look that fits your brand.
---
## Best Practices
- **Consistency with Your Brand**: Choose the theme that best aligns with your platform's overall design and user experience. You can also apply custom branding elements via CSS to make the builder interface feel more integrated with your application.
- **Light vs. Dark Mode**: Offering both light and dark themes provides users with a more personalized experience. You can also allow users to switch between themes based on their preferences, enhancing accessibility and comfort, especially in low-light environments.
By utilizing this feature, you can create a visually appealing and cohesive environment for your users, ensuring that the builder interface matches your application's aesthetic while providing a comfortable and flexible design experience.
---
## Body Values
You can programmatically set values for the **Body** panel options.
```javascript
unlayer.setBodyValues({
backgroundColor: '#e7e7e7',
contentWidth: '500px', // or percent "50%"
fontFamily: {
label: 'Helvetica',
value: "'Helvetica Neue', Helvetica, Arial, sans-serif",
},
preheaderText: 'Hello World',
});
```
# Default Body Values
You can set default values for the **Body** panel options.
### Text Color
```javascript
unlayer.init({
tools: {
bodies: {
properties: {
textColor: {
editor: {
defaultValue: '#ff0000',
},
},
},
},
},
});
```
---
### Background Color
```javascript
unlayer.init({
tools: {
bodies: {
properties: {
backgroundColor: {
editor: {
defaultValue: '#ff0000',
},
},
},
},
},
});
```
---
### Font Family
```javascript
unlayer.init({
tools: {
bodies: {
properties: {
fontFamily: {
editor: {
defaultValue: {
value: "'Open Sans',sans-serif",
},
},
},
},
},
},
});
```
---
### Content Alignment
```javascript
unlayer.init({
tools: {
bodies: {
properties: {
contentAlign: {
editor: {
defaultValue: 'center',
},
},
},
},
},
});
```
---
### Font Weight
```javascript
unlayer.init({
tools: {
bodies: {
properties: {
fontWeight: {
editor: {
defaultValue: 700,
},
},
},
},
},
});
```
---
## Disable Columns
This feature allows developers to configure the builder to remove the ability to create or edit multi-column blocks. When columns are disabled, blocks will be restricted to a single column, hiding the Columns tool and any multi-column layout options from the editing panel and the Blocks tab. This ensures that all created content will follow a single-column structure, which can be useful for simplifying designs or following specific layout constraints.
## How It Works
When the columns feature is disabled, the following changes occur:
- The **Columns** tool is removed from the builder's toolbar.
- The ability to add multi-column blocks is disabled.
- Only single-column blocks will be available in the Blocks tab.
## Enabling the Feature
To disable columns, you need to configure the builder by passing the `columns: false` option within the editor configuration. This can be done when initializing the builder.
Example: Disabling Columns
```javascript
cy.loadEditor({
editor: {
columns: false,
},
});
```
In this configuration:
- `columns: false` disables the columns feature, ensuring that only single-column blocks are allowed in the editor.
## Use Case
This feature is useful for applications or use cases where multi-column layouts are not desired or needed. For instance:
- **Mobile-First Designs**: If your design is primarily targeting mobile devices, single-column layouts are typically preferred for readability and simplicity.
- **Simplified User Experience**: Removing the complexity of multi-column blocks can simplify the builder experience for less experienced end-users.
- **Strict Design Guidelines**: If your application enforces strict design guidelines that mandate single-column layouts, this option will ensure that no multi-column blocks are created.
---
## Columns(Columns)
Columns are a fundamental part of the builder's layout system. They allow end-users to organize content in flexible, responsive grids, making it easy to design structured layouts for emails, web pages, or popups. Columns divide the available space in a block into separate sections, where different content elements like text, images, or buttons can be placed.

## How Columns Work
The builder operates on a 12-column grid system. This means that the width of the entire row is divided into 12 equal parts, and you can allocate any number of these parts to a column. The sum of all column widths within a block must always equal 12, ensuring the content fits perfectly into the layout.
## Example Layouts
Here are some examples of how columns can be used in the builder:
- **Single Column Layout**: Takes up the full width (12/12 columns).
- **Two Column Layout**: You can divide the row into two equal parts, each column taking up 6 parts of the grid (6/12 + 6/12).
- **Three Column Layout**: Three columns can be defined with equal or unequal widths, as long as the total is 12 (e.g., 4/12 + 4/12 + 4/12 or 3/12 + 3/12 + 6/12).
## Flexibility in Design
Columns provide immense flexibility in how content is displayed. You can:
- **Organize content** into distinct sections side-by-side (like text next to an image).
- **Create complex layouts** with varying column sizes to suit your design needs.
- **Ensure responsiveness** by stacking columns on smaller screens, such as mobile devices, where the content is automatically adjusted to fit the screen width.
## Managing Columns in the Builder
In the builder, end-users can easily manage columns. They can:
- **Add or remove columns** to structure their layout.
- **Resize columns** by adjusting the width of each section using the drag handles.
- **Reorder content** within columns by dragging and dropping elements between them.
### Predefined Columns
The predefined columns feature allows developers to add default blocks with specific column configurations to the builder. [Learn More](./predefined-columns.md)
### Disable Columns
This feature allows developers to configure the builder to remove the ability to create or edit multi-column blocks. [Learn More](./disable-columns.md)
### Locking columns
When on Editor Mode, you can mark individual columns as "Locked" or "Non Deletable" to prevent your users from removing them from the template or editing their content. [Learn More](../templates/permissions)
---
## Predefined Columns
The predefined columns feature allows developers to add default blocks with specific column configurations to the builder. This feature uses the `registerColumns` function to create blocks with fixed column sizes. Each block can contain up to 12 columns, and the sum of the column sizes must always equal 12.
This feature provides flexibility to design pre-configured blocks that can speed up the design process for end-users and ensure layouts follow certain guidelines.
We have a few pre-built blocks available with different column sizes. You can add your own blocks with custom column sizes.

## How It Works
The `registerColumns` function accepts an array of numbers as a parameter. The array defines the column sizes for a block. Each number represents the width of a column, and the sum of these numbers must always equal 12, as the builder's grid system is based on 12 columns.
## Basic Usage
To create a custom block with predefined columns, call the `unlayer.registerColumns()` function and pass an array that defines the column widths.
### Example: 4 Columns
In this example, we will create a block with 4 columns:
- The first and last columns will each take 2 parts of the grid (smaller).
- The two middle columns will each take 4 parts of the grid (bigger).
```javascript
unlayer.registerColumns([2, 4, 4, 2]);
```
This results in a block with the following layout:

### Example: 3 Columns
In this example, we will create a block with 3 columns:
- The first two columns will each take 3 parts of the grid.
- The last column will take 6 parts of the grid.
```javascript
unlayer.registerColumns([3, 3, 6]);
```
This results in a block with the following layout:

---
## Guidelines for Using registerColumns
- **Grid System**: The builder's grid system is based on 12 columns. This means that the sum of the numbers in the array passed to registerColumns must always equal 12.
- **Column Flexibility**: You can define columns of varying sizes, but they must fit within the 12-column grid.
- **Customization**: Developers can use this feature to provide end-users with pre-configured layouts that suit specific use cases or design guidelines.
---
## Practical Use Cases
Here are some scenarios where pre-defined columns in blocks can be useful:
- **Standardized Layouts**: Ensure that end-users follow predefined layout structures for brand consistency.
- **Design Templates**: Speed up the design process by providing commonly used column configurations as ready-to-use blocks.
- **Responsive Design**: Define column sizes that adapt well to different screen sizes, especially when targeting mobile or tablet devices.
---
## Content Audit
The Audit tab flags common issues so you can address them before finishing your email or page design. It currently looks for accessibility checks so you can identify and fix common issues during the design process. It addresses:
- Missing alternate text for images
- Missing links in buttons or menus
- Missing image URLs
As an added bonus, by using the Audit tab and learning from the issues it flags, you can become more practiced and aware of these considerations so they’re part of your design and development workflow instead of an afterthought.
---
## Enable / Disable
You can enable or disable this feature.
```javascript
unlayer.init({
features: {
audit: true,
},
});
```
---
## Custom Validators
If you want to run the content for other types of issues, you can create custom validators.
### Global Validator
A global validator can be used to identify general design issues. For example, if you want to run and analyze the content for potential spamminess.
```javascript
unlayer.setValidator(function (data) {
const { html, design, defaultErrors } = data;
return [
{
id: 'DESIGN_CUSTOM_ERROR',
icon: 'fa-smile',
severity: 'WARNING',
title: 'This is a custom error',
description:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce pretium non lectus venenatis lacinia. Fusce vitae venenatis nibh. Sed laoreet ornare lorem, a convallis nibh suscipit eu. Ut dictum commodo velit vitae interdum.',
},
].concat(defaultErrors);
});
```
Each error needs to have the following parameters.
| Name | Description |
| :-------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **id** | A unique ID for the error type |
| **icon** | Icon that users see in the errors tab. The value can be class name of any [fontawesome icon](https://fontawesome.com/icons), or a URL for your icon's image |
| **severity** | `ERROR` or `WARNING` |
| **title** | Title of the error message |
| **description** | Description of the error message |
---
### Tool Validator
Similar to the global validator, you can also create custom validators for different tools. This can be done for built-in tools, as well as custom tools, using 'custom#tool_name' as the first argument. These validators can check and validate different values of the tools' properties.
Here's an example of attaching a validator to the `button` tool.
```javascript
unlayer.setToolValidator('button', function (data) {
const { defaultErrors, values } = data;
return [
{
id: 'CUSTOM_ERROR',
icon: 'fa-smile',
severity: 'WARNING',
title: 'This is a custom button error',
description: `Lorem ipsum dolor sit amet ${values?.text}.`,
},
].concat(defaultErrors);
});
```
### Translations (i18n) for Validators
The title and description for each error can include translated text to keep it i18n compliant. You can read more about our [localization](../../advanced-configuration/localization.md).
```javascript
unlayer.init({
translations: {
en: {
custom_translation_title: 'This is a custom title',
custom_translation_desc: 'This is a custom description.',
},
},
});
unlayer.setToolValidator('button', function (data) {
return [
{
id: 'CUSTOM_ERROR',
icon: 'fa-smile',
severity: 'WARNING',
title: 'custom_translation_title',
description: 'custom_translation_desc',
},
];
});
```
---
## Check Status
You can check if there are any active errors or warnings in the design by using the following method.
```javascript
unlayer.audit(function (data) {
console.log(data);
// {
// status: "PASS",
// errors: [],
// }
// {
// status: "FAIL",
// errors: [{...}],
// }
});
```
---
## Spam Example
Here's an async example where you can use an external API to check your content for spam.
```javascript
unlayer.setValidator(async (data) => {
const { html, design, defaultErrors } = data;
const errors = [];
// Check HTML for spamminess using a third-party API
const isSpammy = await isHtmlSpammy(html);
if (isSpammy) {
errors.push({
id: 'SPAM_ERROR',
icon: 'fa-image',
severity: 'WARNING',
title: 'Content is Spammy',
description: 'The content seems to be spammy. Fix your content.',
});
}
return errors.concat(defaultErrors);
});
```
---
## Headers and Footers
Unlayer provides built-in Header and Footer Support, allowing designs to be divided into clearly defined sections: Header, Content and Footer. This helps end-users structure their designs more logically, simplifies template creation, and ensures better consistency across designs.
When enabled, the editor visually highlights the Header and Footer areas inside the canvas with distinct indicators, making it easy for users to understand which part of the content they are working on.
## Key Benefits
- **Clear Visual Separation**: Dedicated header and footer areas are displayed at the top and bottom of the design canvas.
- **Improved Design Consistency**: Helps enforce structural best practices across all designs.
- **Simplifies Template Creation**: End-users can easily place branding, navigation, and legal information in appropriate sections.
- **Stored in Design JSON**: The header/footer structure is fully preserved in the design data model.
## Enabling Headers and Footers
You can enable this feature by passing a feature flag during initialization:
```javascript
unlayer.init({
features: {
headersAndFooters: true,
},
});
```
Once enabled:
- The editor will display Header and Footer sections with visible labels.
- Users can independently add content inside each section using the existing tools.
- Content added inside the Header or Footer remains logically grouped in the design JSON.
## Design Behavior
- **Header**: Shown at the top of the canvas, ideal for logos, navigation, or branding elements.
- **Content**: All content between header and footer remains fully editable.
- **Footer**: Shown at the bottom, ideal for legal disclaimers, contact details, unsubscribe links, etc.
## Notes
- This feature only modifies the editor interface and design structure; exported outputs remain as standard HTML or PDF content.
- When disabled, the editor operates as a single continuous body without any section divisions.
---
## Min / Max Rows
You can set a limit of minimum and maximum rows that can be added in the editor. This is particularly useful in some cases where you want to restrict the number of rows.
```javascript
unlayer.init({
editor: {
minRows: 1,
maxRows: 2,
},
});
```
---
## Content Settings
This section allows you to configure essential aspects of the content structure and layout within the designs. These settings give developers control over the default styles and behaviors of content, ensuring a consistent experience for end-users.
Through this section, you can:
- **Set Default Body Values**: Define the default background color, font family, content width, and preheader text to ensure brand consistency across all templates.
- **Page Anchors**: Enable users to define sections of a page and create internal links, making it easy to link buttons or text to specific areas of the design.
- **Custom Column Sizes and Row Limits**: Configure default column sizes and set minimum or maximum rows allowed in your designs.
By configuring these settings, developers can ensure that the content structure is well-organized, flexible, and tailored to the specific needs of their users, while maintaining consistency across different templates.
The following editor options are available:
---
## Page Anchors
:::warning
Page anchors are not available when AMP is enabled.
:::
This is a great feature for linking within a page. It lets users define sections of a page and then link buttons or links to those sections.

You can enable this using the following code.
```javascript
unlayer.init({
features: {
pageAnchors: true,
},
});
```
---
## Page Title
You can pass a custom title to the `unlayer.exportHtml()` method to set the title of the exported HTML during the export process.
```javascript
unlayer.exportHtml(console.log, { title: 'Exported HTML Title' });
```
---
## Preheader Text
If you are using the **email builder**, end-users can set a preheader. A preheader is the short summary text that follows the subject line when viewing an email from the inbox.
By default, preheader text is turned on. If you want to disable it, you can turn it off.
```javascript
unlayer.init({
features: {
preheaderText: false,
},
});
```
---
## Auto Select On Drop
When a user drags and drops a tool in their design, the default behavior is not to automatically select the tool and show its properties in the editor panel. If you turn this option to `true`, it will automatically select the tool when it's dropped.
```javascript
unlayer.init({
editor: {
autoSelectOnDrop: true,
},
});
```
---
## Blank Design
The editor loads with a blank design by default. You can use this function to reset the design to blank state and let the user can start from scratch.
You can also specify a background color for the body here.
```javascript
unlayer.loadBlank({
backgroundColor: '#e7e7e7',
});
```
---
## Confirm On Delete
When a user deletes any content in their design, the default behavior is not to show a confirmation message. If you have the [Undo / Redo](./undo-redo.md) feature turned off, then you may want to enable this feature.

You can enable this feature by passing the following parameter to your initialization code.
```javascript
unlayer.init({
editor: {
confirmOnDelete: true,
},
});
```
---
## Editor Behavior
This section allows you to configure how the editor operates and responds to user interactions. These settings give developers control over the editor's functionality, ensuring a smooth, intuitive user experience while working with the drag-and-drop interface.
By fine-tuning these behavior settings, you can optimize how the editor functions for your end-users, ensuring it behaves consistently with their expectations and workflows.
The following editor options are available:
---
## Preview Designs
This feature allows end-users to toggle between viewing their design in mobile, tablet or desktop mode, ensuring responsiveness and compatibility across different devices. It also allows end-users to view their emails in different email clients to ensure email client compatibility. This feature can be controlled programmatically, hidden, or customized to show dynamic content through custom HTML.
## Enable or Disable
By default, the preview icon is visible in the actions bar, but you can choose to hide it using the `unlayer.init()` configuration.
Example:
```javascript
unlayer.init({
features: {
preview: false, // Disable mobile/desktop preview icons
},
});
```
Setting `preview: false` will hide the preview toggle icons in the editor’s actions bar.
---
## Programmatically Controlling Preview Mode
You can also show or hide the preview mode programmatically, allowing developers to control when the preview is shown and which device mode (mobile or desktop) is active.
### Show Desktop Preview
```javascript
unlayer.showPreview('desktop');
```
This method open the preview in desktop mode, showing how the design will look on a desktop screen.
### Show Mobile Preview
```javascript
unlayer.showPreview('mobile');
```
This method open the preview in mobile mode, showing how the design will look on a mobile device.
### Hide Preview
```javascript
unlayer.hidePreview();
```
This method hides the preview mode, returning the user to the normal editing interface.
---
## Custom Device Resolutions
You can customize the available device resolutions options in the preview mode by defining custom resolutions with specific dimensions. This allows you to test your designs at exact viewport sizes that match your target devices or breakpoints.
### Basic Device Resolutions Example
```javascript
unlayer.init({
features: {
preview: {
enabled: true,
deviceResolutions: {
showDefaultResolutions: true, // Show built-in device options
customResolutions: {
desktop: [
{ value: 1000, name: 'Medium Desktop' },
{ value: 1440, name: 'Large Desktop' },
],
tablet: [
{ value: 500, name: 'Small Tablet' },
{ value: 768, name: 'iPad Portrait' },
],
mobile: [
{ value: 300, name: 'Small Mobile' },
{ value: 375, name: 'iPhone X' },
],
},
},
},
},
});
```
Custom device resolutions can be organized into three categories:
| Category | Description |
| :---------- | :---------------------------------------------- |
| **desktop** | Large screen devices (typically > 768px) |
| **tablet** | Medium screen devices (typically 768px - 480px) |
| **mobile** | Small screen devices (typically < 480px) |
Each custom device resolution object supports the following properties:
| Option | Description |
| :-------- | :-------------------------------------------- |
| **value** | The viewport width in pixels (number) |
| **name** | The display text shown in the device dropdown |
### Hide Default Resolutions
If you want to show only your custom device resolutions and hide the default ones:
```javascript
unlayer.init({
features: {
preview: {
enabled: true,
deviceResolutions: {
showDefaultResolutions: false, // Hide built-in device options
customResolutions: {
desktop: [
{ value: 1920, name: 'Full HD Desktop' },
{
value: 1366,
name: 'Standard Desktop',
},
],
tablet: [
{ value: 1024, name: 'iPad Pro 11"' },
{ value: 912, name: 'Surface Pro' },
],
mobile: [
{ value: 390, name: 'iPhone 14' },
{ value: 360, name: 'Samsung Galaxy S23' },
],
},
},
},
},
});
```
---
## Customizing Preview with Custom HTML
In cases where you want to control what is displayed in preview mode, you can use custom HTML. This is especially useful if you’re using a templating library to render dynamic content in the preview mode.
To achieve this, you can register a callback function using the `unlayer.registerCallback()` method and pass your custom HTML to the preview mode.
```javascript
unlayer.registerCallback('previewHtml', function (params, done) {
// You can manipulate the params.html here or replace it with custom HTML
done({
html: params.html, // you can pass your custom html here
});
});
```
This allows you to fully control what the preview mode renders, whether it’s static content or dynamic content parsed through a templating engine.
---
## Best Practices
- **Device Compatibility**: It’s recommended to keep the preview functionality enabled so users can switch between mobile and desktop views to ensure their design is responsive.
- **Custom Previews for Dynamic Content**: When rendering dynamic or personalized content, use the custom HTML feature to show how the final design will look in preview mode. This ensures the template renders accurately with real data.
By leveraging the preview feature, developers can provide end-users with a powerful tool to ensure that their designs are responsive across all devices, while maintaining the flexibility to customize and control how previews are displayed programmatically.
---
## Responsive Controls
In modern web design, managing responsiveness is crucial, especially when dealing with content layouts that need to adapt across different devices. Unlayer builder provides a few important features to help developers and end-users manage responsiveness in their designs:
- **Do Not Stack on Mobile**: By default, columns in a block are set to stack on mobile devices for a better user experience. End-users have the option to change this setting for each block.
- **Hide on Mobile**: Allows specific content to be hidden on mobile devices, improving control over mobile layouts.
By default, these options come with preset values, but developers can customize the default settings to suit the needs of their applications.
---
# Customizing Mobile Controls
You can change the default behavior for **Do Not Stack on Mobile** and **Hide on Mobile** by configuring the `unlayer.init()` method. This is done through the `_override` option in the editor configuration, which allows you to set default values for mobile settings.
## Example: Changing Default Value for "Do Not Stack on Mobile"
The "Do Not Stack on Mobile" property is only available for blocks (rows).
```javascript
unlayer.init({
tools: {
rows: {
properties: {
noStackMobile: {
editor: {
_override: {
mobile: {
defaultValue: true, // Default value for 'Do Not Stack on Mobile'
},
},
},
},
},
},
},
});
```
### Example Explained
- **tools**: This top-level object represents all the available tools in the editor (e.g., rows, images, text, etc).
- **rows**: Refers to the row tool, which is commonly used to manage the layout structure in the design.
- **properties**: This object contains the various properties that can be modified for the row tool.
- **noStackMobile**: This property refers to the "Do Not Stack on Mobile" feature. When enabled, columns in rows won't stack vertically on smaller screens like mobile devices.
- **\_override**: This setting allows you to change the default behavior of the property for a non-desktop device.
- **mobile.defaultValue**: Defines the default value for the "Do Not Stack on Mobile" option. Setting it to true ensures that blocks do not stack by default on mobile devices.
## Example: Changing Default Value for "Hide on Mobile"
In this example, we will change the default value for "Hide on Mobile" for **Button** tool. This means that buttons will be hidden on mobile devices by default. This is for demonstration only and not a good idea in practice.
```javascript
unlayer.init({
tools: {
button: {
properties: {
hideMobile: {
editor: {
_override: {
mobile: {
defaultValue: true,
},
},
},
},
},
},
},
});
```
:::info Note
The end-user still has the option to toggle this off in the builder
:::
---
## Use Cases
**Custom Mobile Layouts**: Applications with unique mobile design requirements can modify the default values for mobile responsiveness to ensure that content behaves as expected without requiring the end-user to manually change the settings.
**Consistent Branding**: Developers can enforce consistent branding guidelines by adjusting the default behavior for mobile stacking and visibility to match their brand's responsive design standards.
---
## Notes
- Setting a default value through the \__override_ property will ensure that newly created content in the builder start with the specified behavior, but end-users can still manually change the option if needed.
- This feature gives developers more control over the initial design behavior on mobile devices, making it easier to maintain responsive designs across different applications.
---
## Undo / Redo
This feature allows end-users to easily revert or reapply changes made in the builder, providing flexibility and control over their design process. This functionality ensures that end-users can experiment with content creation without the fear of making permanent mistakes.
## Enable / Disable
You can enable or disable the Undo/Redo feature by configuring it within the `unlayer.init()` method. By default, this feature is enabled, but you can disable it if needed. If you choose to disable it, you may want to turn on [delete confirmation](./confirm-on-delete.md) setting.
Example:
```javascript
unlayer.init({
features: {
undoRedo: false,
},
});
```
Setting `undoRedo` to false disables the undo/redo functionality in the builder. If you want to re-enable it, set `undoRedo: true` or omit this property entirely, as it’s enabled by default.
## Additional configuration
You can control whether the editor should select the element related to the undo/redo action, and whether the text input should be focused or not, with the following options:
```javascript
unlayer.init({
features: {
undoRedo: {
enabled: true,
autoSelect: true,
autoFocus: true,
},
},
});
```
## Undo/Redo API Methods
You can also control this feature through several API methods that allow for precise management of user actions.
### Undo Last Action
The `unlayer.undo()` method reverts the last action taken in the editor.
Example:
```javascript
unlayer.undo();
```
This method undoes the last change made by the user.
### Redo Last Action
The `unlayer.redo()` method reapplies the most recently undone action.
Example:
```javascript
unlayer.redo();
```
This method redoes the last undone action, allowing users to move forward through their action history.
### Check if Undo is Available
The `unlayer.canUndo()` method checks if there are any actions in the queue that can be undone.
```javascript
if (unlayer.canUndo()) {
console.log('There are actions to undo.');
}
```
This method returns a boolean (true or false), indicating whether any actions are available to undo.
### Check if Redo is Available
The `unlayer.canRedo()` method checks if there are any actions in the queue that can be redone.
```javascript
if (unlayer.canRedo()) {
console.log('There are actions to redo.');
}
```
This method returns a boolean (true or false), indicating whether any actions are available to redo.
---
## Best Practices
- **Undo/Redo User Experience**: It’s recommended to keep the Undo/Redo feature enabled to allow users more flexibility in editing and modifying their designs without fear of mistakes. If you choose to disable the undo/redo feature, it is highly recommended to turn on the [Confirm On Delete](./confirm-on-delete.md) feature.
- **Custom UI**: If you are adding the Undo/Redo buttons in your UI, use `unlayer.canUndo()` and `unlayer.canRedo()` methods to determine when to enable or disable the undo/redo buttons in your UI, providing a smooth user experience.
By utilizing the Undo/Redo feature, developers can provide their end-users with the ability to easily correct mistakes or backtrack on their designs, enhancing both usability and confidence in the builder's capabilities.
---
## Accessibility
This feature enables developers to configure and manage accessibility options in the builder. With this API, you can define a custom iframe title for the builder and specify a custom title for the exported HTML.
## Editor Configuration
```javascript
unlayer.init({
editor: {
title: 'Editor Iframe Title',
},
});
```
In this example, the builder iframe title is set to "**Editor Iframe Title**".
## HTML Title Tag for Exported HTML
You can set the HTML `` tag for exported HTML in three ways, with the following priority order:
### 1. Export Options (Highest Priority)
You can pass a title when calling `exportHtml`:
```javascript
unlayer.exportHtml(
function (data) {
var html = data.html;
},
{
title: 'My Custom Page Title',
},
);
```
### 2. Design Body Properties (Medium Priority)
Users can set the HTML `` tag directly in the editor UI through the **Body** panel under the **Accessibility** section. This allows non-technical users to configure the title without code changes.
### 3. Editor Configuration (Lowest Priority)
If no title is specified in the export options or the body properties, the title from the editor configuration will be used:
```javascript
unlayer.init({
editor: {
title: 'Default Page Title',
},
});
```
This fallback ensures that there's always a `` tag value available for the exported HTML.
---
## Color Management
This feature allows developers to configure and control the color options available in the builder, ensuring brand consistency and flexibility for end-users. By using this API, you can now define custom color groups, set default color groups, and disable existing color options to suit your specific needs. This flexibility ensures that end-users have access to the right color schemes for their design needs, including support for custom color groups like dark mode.
## Key Features
- **Custom Color Groups**: Create and define custom color categories beyond the default “Common Colors” and “Brand Colors.”
- **Default Color Group**: Set a default color group in the color picker dropdown for ease of use.
- **Disabling Color Groups**: Disable specific built-in color groups, such as “Template Colors,” if they are not needed.
You can change the default color presets for the color picker.
```javascript
unlayer.init({
features: {
colorPicker: {
presets: [
'#D9E3F0',
'#F47373',
'#697689',
'#37D67A',
'#2CCCE4',
'#555555',
'#DCE775',
],
},
},
});
```
---
## Custom Color Groups
You can define any custom color group, giving you the flexibility to add categories that suit your application.
```javascript
unlayer.init({
features: {
colorPicker: {
colors: [
{
id: 'dark_colors',
label: 'Dark Mode',
colors: ['#000', '#333', '#666'],
},
],
},
},
});
```
In this example, a custom color group labeled “**Dark Mode**” with specific colors is added to the color picker.
---
## Setting Default Color Group
You can now specify which color group should be set as the default in the color picker dropdown by using the `default: true` flag.
```javascript
unlayer.init({
features: {
colorPicker: {
colors: [
{
id: 'brand_colors',
colors: ['pink'],
default: true,
},
],
},
},
});
```
Here, the “**Brand Colors**" group is set as the default color group.
---
## Disable Existing Color Groups
You can also disable any of the existing color groups, such as “**Template Colors**”, by passing an empty array for the group’s colors.
```javascript
unlayer.init({
features: {
colorPicker: {
colors: [
{
id: 'template_colors',
colors: [], // Disables the Template Colors group
},
],
},
},
});
```
This example disables the “**Template Colors**” group by setting its colors array to empty.
---
## Overriding Existing Color Groups
To override default color groups like “Common Colors” or “Brand Colors”, use the following structure:
```javascript
unlayer.init({
features: {
colorPicker: {
colors: [
{
id: 'common_colors', // Override Common Colors
colors: ['blue'],
},
{
id: 'brand_colors', // Override Brand Colors
colors: ['pink'],
default: true,
},
],
},
},
});
```
Here, the “Common Colors” group is overridden with a single color (“blue”), and the “Brand Colors” group is overridden with “pink” and set as the default.
---
## Updating Color Picker Configuration on the Fly
In addition to setting the configuration during initialization, you can update the color picker settings dynamically using the `unlayer.setColorPickerConfig()` method.
```javascript
unlayer.setColorPickerConfig({
colors: [
{
id: 'light_colors',
label: 'Light Mode',
colors: ['#FFF', '#EEE', '#CCC'],
},
],
});
```
This allows you to modify the color picker configuration after initialization, providing flexibility to change settings based on user interactions or preferences.
---
## FAQ
**Can I completely remove the default color groups?**
Yes, you can disable any default color group (e.g., “Template Colors”) by passing an empty array for that group’s colors.
**How do I add multiple custom color groups?**
You can define multiple custom color groups by adding multiple objects within the colors array, each with a unique id and label.
**Can I dynamically update the color picker settings?**
Yes, you can use `unlayer.setColorPickerConfig()` to update the color picker settings at any point after initialization.
---
By using the color management API, developers can fully customize the color options available in the builder, ensuring flexibility and branding control for their end-users.
---
## Device Management
The editor supports content creation for multiple devices such as `Mobile` and `Desktop` out of the box. However, in some cases, you may want to restrict the editor to a mobile-only or desktop-only mode or keep mobile mode as the default mode.
## Supported Devices
By default, the editor supports `desktop` and `mobile` devices. You can choose to disable one by removing it.
```javascript
unlayer.init({
devices: ['desktop', 'mobile'],
});
```
---
## Default Device
When the editor is initialized, it opens in the default device mode that is selected here. You can choose to change the default mode to mobile mode if required.
```javascript
unlayer.init({
defaultDevice: 'desktop',
});
```
---
## Mobile-Only Mode
If you want to run the editor in mobile-only mode, you can use the following code.
```javascript
unlayer.init({
defaultDevice: 'mobile',
devices: ['mobile'],
});
```
---
## Domain Management
### Whitelisting Domains for Production Use
When testing Unlayer locally, the domain `localhost` is automatically whitelisted. This allows you to access the full set of plan features and ensures that the Unlayer watermark is removed during development.
However, in a **production (or live) environment**, you must explicitly add your domain to the Unlayer Console for the builder to function correctly and reflect your subscription plan.
### How to Whitelist Your Domain
1. Open the page where the Unlayer builder is embedded.
2. Right-click anywhere on the page and select **Inspect**.
3. Navigate to the **Network** tab and refresh the page.
4. Look for a network request named **`session`** and open its **Payload**.
5. Locate the **`domain`** property in the payload data.
6. Copy the domain value.
7. Go to your [Unlayer Console](https://console.unlayer.com/) and navigate to:
**Developer** → **Builder** → **Settings** → **Allowed Domains**
8. Paste the domain into the **Allowed Domains** field and save.
9. Refresh the page where you are loading the builder. You should now see the correct plan features, and the Unlayer watermark will be removed.
---
## Design Tags
Design Tags are customizable placeholders that allow you to personalize email or page templates dynamically during the editing process. Unlike [Merge Tags](./merge-tags.md), which replace content when an email is sent, design tags only appear within the builder when the template is being edited by users.
For example, you can use design tags to display a personalized message like “Welcome to **My Business**,” where **My Business** dynamically changes based on the business name of the user editing the template.
:::info Exported Design
The final design that is exported does **not** have the variables but the replacement that the end-user sees.
:::
---
## Key Features
- **Customization in the Builder**: Design tags allow for personalized content in the template when it’s being edited in the builder.
- **HTML-Safe Tags**: Use HTML-safe tags to prevent breaking the layout or introducing errors in the template.
- **Flexible Delimiters**: You can customize the tag delimiters to suit your specific syntax requirements.
---
## How Design Tags Work
Design tags are defined using **double square brackets** `[[ var ]]`. These tags are replaced with dynamic content while the template is being edited in the builder. For example, the tag `[[ business_name ]]` could be replaced with the value “Tesla Inc” while editing.
To ensure tags are **HTML-safe** (i.e., to escape special characters or preserve formatting), you can use an additional curly bracket: `[[{ var }]]`. This is especially important when passing URLs for **images** or **links**, ensuring they are safely inserted into the HTML.
---
## Initializing the Builder with Design Tags
You can pass design tags as part of the `unlayer.init()` function when initializing the builder. Design tags are defined as key-value pairs, where the key represents the variable in the template, and the value is the dynamic content that will be displayed in place of the tag.
**Example: Passing Design Tags During Initialization**
```javascript
unlayer.init({
designTags: {
business_name: 'Tesla Inc',
current_user_name: 'Elon Musk',
business_logo_url: 'https://example.com/logo.png',
unsubscribe_link: 'https://example.com/unsubscribe',
},
});
```
In this example:
- The design tag `[[ business_name ]]` will be replaced with “Tesla Inc.”
- The design tag `[[ current_user_name ]]` will be replaced with “Elon Musk.”
- The design tag `[[{ business_logo_url }]]` will be replaced with the URL for the business logo.
- The design tag `[[{ unsubscribe_link }]]` will be replaced with the unsubscribe link URL.
## Updating Design Tags Dynamically
You can also update design tags dynamically at any point after the builder has been initialized by using the `unlayer.setDesignTags()` method. This is useful when you want to update tags based on user actions or changes in real-time.
**Example: Updating Design Tags Dynamically**
```javascript
unlayer.setDesignTags({
business_name: 'SpaceX',
current_user_name: 'John Doe',
});
```
In this example:
- The design tag `[[ business_name ]]` is updated to “SpaceX.”
- The design tag `[[ current_user_name ]]` is updated to “John Doe.”
---
If your template contains the following:
```
Welcome to [[business_name]].
Yours Truly,
[[current_user_name]]
```
When editing the template in the builder, it will display:
```
Welcome to Tesla inc.
Yours Truly,
Elon Musk
```
---
## HTML-Safe Design Tags
To make the content **HTML-safe** and avoid issues with rendering dynamic content in the editor, you can use triple curly brackets in the tag: `[[{ var }]]`. You must do this if you are passing **URLs** for **images** or **links**.
**Example: HTML-Safe Design Tags**
```text
[[{ business_logo }]]
```
In this case, the value for `[[{ business_name }]]` will be displayed as “Tesla Inc,” and any special characters in the value will be safely rendered in the HTML.
---
## Customizing Delimiters
If the default delimiter `[[ ]]` conflicts with your template or is not suitable for your syntax, you can customize the delimiters used for design tags.
```javascript
unlayer.init({
designTagsConfig: {
delimiter: ['((', '))'], // Change delimiters to (( ))
},
designTags: {
business_name: 'Tesla Inc',
current_user_name: 'Elon Musk',
},
});
```
In this example:
- The delimiter has been changed from `[[ ]]` to `(( ))`.
- Now, the design tag `<< business_name >>` will be replaced with “Tesla Inc.”
This is useful when \[[ ]] conflicts with other syntax or templates being used in your application.
---
## Key Differences Between Design Tags and Merge Tags
It’s important to note the difference between design tags and merge tags:
- **Design Tags**: These are placeholders that dynamically change during the editing process in the builder. They are visible only to the person editing the template in the builder.
- **Merge Tags**: These are placeholders that are replaced with actual content (e.g., user-specific data) when an email is sent to a recipient.
While design tags help personalize the template for the editor, merge tags allow for dynamic content when sending emails to end recipients.
---
## Common Use Cases for Design Tags
Some typical use cases for design tags in templates include:
- **Personalized Business Name**: Show the business name of the user editing the template (e.g., `[[ business_name ]]`).
- **Personalized Business Logo**: Display a dynamic URL for the business logo using an HTML-safe tag (e.g., `[[{ business_logo_url }]]`).
- **Custom Footer Text**: Display a footer text specific to the user or business editing the template.
- **Custom Unsubscribe Link**: Use an HTML-safe tag to generate personalized unsubscribe links for the email template (e.g., `[[{ unsubscribe_link }]]`).
These use cases help personalize the editing experience and ensure that the correct dynamic content is visible while users edit their templates in the builder.
---
## Display Conditions
The Display Conditions feature allows your end-users to show or hide specific content in an email based on customizable conditions. This is useful for creating personalized email campaigns that adapt to recipients’ behaviors or attributes, such as gender, purchase history, location, or preferences.
End-users can choose display conditions for different blocks when designing their emails, ensuring that recipients see tailored content based on predefined rules.

Your users will have the ability to pick a condition and apply it to a row so they can show different content based on who the audience is.
---
## Enable Feature
The `unlayer.setDisplayConditions` API lets you define display conditions that users can apply to email blocks. Each condition specifies content visibility based on a particular rule, such as a user’s last order catalog or any custom attribute you define.
**Example: Setting Display Conditions**
```javascript
unlayer.setDisplayConditions([
{
type: 'Last Order Catalog',
label: 'Women',
description: 'Only people who ordered from Women catalog',
before: "{% if lastOrder.catalog == 'Women' %}",
after: '{% endif %}',
},
{
type: 'Last Order Catalog',
label: 'Men',
description: 'Only people who ordered from Men catalog',
before: "{% if lastOrder.catalog == 'Men' %}",
after: '{% endif %}',
},
{
type: 'Last Order Catalog',
label: 'Children',
description: 'Only people who ordered from Children catalog',
before: "{% if lastOrder.catalog == 'Children' %}",
after: '{% endif %}',
},
]);
```
This example creates three conditions based on the recipient’s last order catalog. End-users can apply these conditions to different email blocks, so that each block will only be displayed to recipients who meet the selected condition.
Your end-users will see these options in a modal.

---
### Attributes for Display Conditions
Each display condition must have the following attributes. You can also send any custom attributes to identify the condition.
| Attribute | Description |
| :-------------- | :------------------------------------------------------------------------------------------------------------------------------ |
| **type** | A category to group multiple conditions into folders. End-users will select the condition type first. |
| **label** | The name of the condition that the user will see. |
| **description** | A description of the condition that will help users understand when it applies. |
| **before** | The opening syntax for the condition (e.g., an opening tag or logic statement). This will be added **before** the selected row. |
| **after** | The closing syntax for the condition (e.g., a closing tag or logic statement). This will be added **after** the selected row |
### Example: Attribute Breakdown
```json
{
"type": "Last Order Catalog",
"label": "Women",
"description": "Only people who ordered from the Women catalog",
"before": "{% if lastOrder.catalog == 'Women' %}",
"after": "{% endif %}"
}
```
- **type**: Groups this condition under “Last Order Catalog.
- **label**: Displays the name “Women” in the UI.
- **description**: Provides a helpful description for end-users: “Only people who ordered from the Women catalog.”
- **before/after**: Defines the template syntax for wrapping content, where the content is only shown if the recipient’s last order catalog is “Women.”
---
## Custom UI for Display Conditions
You can extend this feature by creating a custom UI that allows your end-users to build their own conditions using an interface that you control. For example, you could display a custom modal where users select or build conditions before applying them to their email content.
```javascript
unlayer.registerCallback('displayCondition', function (data, done) {
// `data` contains the current display condition and a context object with the related element id
// Open your custom UI for building display conditions
// This could be a modal or any interface you control
// Once the user has built or selected a condition, call the `done` callback
// with the new condition.
done({
type: 'Last Order Catalog',
label: 'Women',
description: 'Only people who ordered from the Women catalog',
before: "{% if lastOrder.catalog == 'Women' %}",
after: '{% endif %}',
});
});
```
In this example:
- **data**: This parameter contains the current display condition that the user is working with and a context object with the related element id.
- **Custom UI**: You can open your own modal or custom interface for the user to build or select a display condition.
- **done()**: After the user has selected or created a display condition, call the done function with the new condition. This condition will then be applied to the selected email block.
### Example Workflow for Custom UI Implementation
1. **Open Custom Modal**: When the user selects to add or edit a display condition, open a custom modal or interface where they can select or create a new condition.
2. **Define the Condition**: In your custom UI, let the user define the display condition by selecting attributes like type, label, description, and conditions for showing the block.
3. **Pass the Condition Back to the Builder**: Once the user finalizes the condition, pass it back to the builder by calling the `done()` callback with the appropriate `type`, `label`, `description`, `before`, and `after` values.
4. **Save and Apply the Condition**: The condition is now applied to the block, ensuring that the content is shown only when the recipient meets the specified criteria.
---
## Use Cases for Display Conditions
1. **Personalized Email Campaigns**: Create targeted emails where different content is displayed based on recipient behavior, such as their gender, last purchase, location, or preferences.
2. **Dynamic Content**: Use display conditions to dynamically alter content within a single email template, reducing the need to create separate emails for different audience segments.
3. **Advanced Segmentation**: Set up advanced segmentation by defining custom conditions that control when specific blocks of content are shown to recipients.
---
## Dynamic Images
Dynamic images give each recipient their own unique image using any subscriber data you have in your email service provider. These images change depending on one or more [merge tags](./merge-tags.md) that are passed to the system that delivers them.
Some examples where dynamic images can be used are:
- Personalized Cards
- Countdown Timers
- Live Ads
- Flight Tracker
- Live Charts
- Product Recommendations
- etc
Let's check out an example of personalized birthday cards.
---
## Personalized Birthday Cards
You can send a birthday card to thousands of email subscribers, each personalized with the subscriber's first name. It's simple to do with dynamic images. There are services that can generate personalized images "on the fly", like [Nifty Images](https://niftyimages.com?utm_source=unlayer).

Here is a personalized image created on Nifty Images to wish a happy birthday to all the subscribers. The editor preview of the image uses a sample name `Katie`, but the URL uses a merge tag `{{first_name}}`. Merge tags act as variables in the URL. When rendering your email, landing page, or popup, the real name will replace the merge tag.
If you carefully look at the image URL in the editor, it is:
`https://img1.niftyimages.com/e75h/npar/8y-i?name={{first_name}}`
It is replaced with **Katie** in the editor because we have provided that as a `sample` value for the merge tag `{{first_name}}`. [Learn more here](./merge-tags.md#previewing-merge-tags).

---
## Merge Tags
Merge Tags allow end-users to dynamically insert personalized content into their emails. These placeholders are replaced with real values when the email is sent, making it easy to create highly personalized and dynamic emails. Merge tags can be inserted into a block of text by clicking the "Merge Tags" button in the text editor toolbar.
If no merge tags are provided during initialization, the "Merge Tags" button will not appear.
## How Merge Tags Work
Merge tags are placeholders that are replaced by real data at the time of sending the email. For example, the merge tag `{{first_name}}` might be replaced with "John" when an email is sent to a recipient.
Unlayer supports all templating engines, whether they use curly brackets `{{ ... }}`, square brackets `[ ... ]`, or any other syntax.

## Initializing Merge Tags
To pass merge tags to the builder, use the `mergeTags` configuration during initialization. Each merge tag requires a name (what the user sees), a value (the actual merge tag in the templating engine), and a sample value for preview purposes.
**Example: Passing Merge Tags**
```javascript
unlayer.init({
mergeTags: {
first_name: {
name: 'First Name',
value: '{{first_name}}',
sample: 'John',
},
last_name: {
name: 'Last Name',
value: '{{last_name}}',
sample: 'Doe',
},
},
});
```
In this example:
- first_name will be displayed as "John" in preview mode.
- last_name will be displayed as "Doe" in preview mode.
---
## Grouping Merge Tags into Sub-Menus
You can group related merge tags into sub-menus for better organization. This is useful for scenarios like grouping address fields under a "Shipping Address" menu.
**Example: Grouping Merge Tags**
```javascript
unlayer.init({
mergeTags: {
shipping_address: {
name: 'Shipping Address',
mergeTags: {
street_1: {
name: 'Street 1',
value: '{{shipping_address.address_1}}',
},
street_2: {
name: 'Street 2',
value: '{{shipping_address.address_2}}',
},
city: {
name: 'City',
value: '{{shipping_address.city}}',
},
state: {
name: 'State',
value: '{{shipping_address.state}}',
},
zip: {
name: 'Zip',
value: '{{shipping_address.zip}}',
},
},
},
},
});
```
In this example, the user will see a "Shipping Address" sub-menu with Street 1, Street 2, City, State, and Zip options.
---
## Updating Merge Tags Dynamically
You can dynamically update merge tags after the builder has been initialized using the `unlayer.setMergeTags()` method.
**Example: Dynamically Updating Merge Tags**
```javascript
unlayer.setMergeTags({
first_name: {
name: 'First Name',
value: '{{first_name}}',
sample: 'John',
},
last_name: {
name: 'Last Name',
value: '{{last_name}}',
sample: 'Doe',
},
});
```
---
## Smart Merge Tags

Smart merge tags improve the user experience by:
- **Highlighting** merge tags within text fields.
- Showing **human-readable names** (e.g., "Customer Name" instead of `{{ customer.name }}`).
- Allowing users to select, replace, and style merge tags easily.
Smart Merge Tags are enabled by default, but you can disable them:
```javascript
unlayer.init({
features: {
smartMergeTags: false,
},
});
```
---
## Conditions and Looping
> Run this example in our [Examples](https://examples.unlayer.com/dynamic_content/merge-tag-loops/) page
Merge tags can be used to create dynamic content with conditions and loops. This allows for advanced personalization, such as displaying product lists or conditional content based on user behavior.
For example, if you want your users to create an order confirmation email template and show all products in that order, you would pass the "Products" merge tags along with some `rules`. The `before` and `after` of the selected rule will be added before and after the block in the exported HTML.
This `before` and `after` can be used to create loops or if-then-else conditions. The syntax will depend on which templating engine the host application uses. In this example, we are using Mustache templating engine.
If you want to add conditions to display content to different segments of audience, check [Display Conditions](./display-conditions.md).
**Example: Adding Loops with Merge Tags**
```javascript
unlayer.init({
mergeTags: {
products: {
name: 'Products',
rules: {
repeat: {
name: 'Repeat for Each Product',
before: '{{#products}}',
after: '{{/products}}',
},
},
mergeTags: {
name: {
name: 'Product Name',
value: '{{name}}',
},
image: {
name: 'Product Image',
value: '{{image}}',
},
},
},
},
});
```
In this example:
- The "Products" merge tag group includes a rule for repeating content for each product.
- The `before` and `after` values define the loop in Mustache syntax.
When the loop is processed, each product will be displayed using the name and image merge tags inside the loop.
Specifying the `rules` will add an additional icon to the blocks in the editor. This icon will be used to select the merge tag group and rules.

Once you click that icon, it will let users pick a merge tag group and a merge rule, and will show the available merge tags after selection. You can then use those tags any where inside that block.

---
## Previewing Merge Tags
Sample values for merge tags can be provided using the `sample` attribute. These values are shown in preview mode to help users visualize what the final email will look like.
**Example: Sample Merge Tag Values**
```javascript
unlayer.init({
mergeTags: {
first_name: {
name: 'First Name',
value: '{{first_name}}',
sample: 'John',
},
last_name: {
name: 'Last Name',
value: '{{last_name}}',
sample: 'Doe',
},
},
});
```
In preview mode, `{{first_name}}` will be replaced with "John," and `{{last_name}}` will be replaced with "Doe."
---
## Autocomplete Menu Trigger
By default, the merge tag autocomplete menu is triggered by the first character of your merge tags. For example, if your merge tags look like `{{ first_name }}`, the trigger will automatically be **`{`**.
If you want to change the trigger character, you can do that like this.
```javascript
unlayer.init({
mergeTagsConfig: {
autocompleteTriggerChar: '@',
},
});
```
To disable the autocomplete feature, you can pass `null`.
```javascript
unlayer.init({
mergeTagsConfig: {
autocompleteTriggerChar: null,
},
});
```
---
## Sorting Merge Tags
By default, merge tags are sorted alphabetically in the menu. You can disable sorting.
```javascript
unlayer.init({
mergeTagsConfig: {
sort: false,
},
});
```
---
## Exporting HTML with Real Values
:::info When are Merge Tags replaced?
Replacing merge tags with actual values is done at the time of sending the email by the host application. Usually, the HTML should be parsed by your templating engine (Mustache, Nunjucks, etc) and the tags are replaced with real values.
:::
In case you want to replace merge tags with real values at the time of exporting HTML, you can use the following examples.
### Values
Replace `{{ first_name }}` with `John` and `{{ last_name }}` with `Doe`.
```javascript
unlayer.exportHtml(
function (data) {
var json = data.design;
var html = data.html;
},
{
mergeTags: {
first_name: 'John',
last_name: 'Doe',
},
},
);
```
### Conditions
The following example can be used to show or hide a block based on a `true | false` condition.
```javascript
unlayer.exportHtml(
function (data) {
var json = data.design;
var html = data.html;
},
{
mergeTags: {
products: {
empty: true,
},
},
},
);
```
### Loops
The following example can be used to provide data to a loop.
```javascript
unlayer.exportHtml(
function (data) {
var json = data.design;
var html = data.html;
},
{
mergeTags: {
products: {
repeat: [{ name: 'Item 01' }, { name: 'Item 02' }],
},
},
},
);
```
---
## Custom UI
You can extend this feature and allow users of the editor to build or choose merge tags using a UI that you control. For example, you can open a custom modal and show any interface to pick or build merge tags or rules.
### Merge Tags
You can use this callback function to show custom UI to pick merge tags.
**Example: Custom Merge Tag Picker UI**
```javascript
unlayer.registerCallback('mergeTag', function (data, done) {
// data - contains current mergeTagGroup, current mergeTagRule, list of mergeTags and a context object with the related element id
// done - callback function
// Open custom UI for merge tags here
// Once your user is done choosing a merge tag, call the "done"
// callback function with the new merge tag.
done({
name: 'First Name',
value: '{{first_name}}',
});
// Alternatively, you can use this api to pass a prefix and a suffix
// This is useful if your custom UI allows adding multiple merge tags, for example,
// so you could set a separator between each addition
done({
mergeTag: {
name: 'First Name',
value: '{{first_name}}',
},
prefix: '',
suffix: ' ',
});
});
```
### Merge Tag Groups
You can use this callback function to show custom UI to pick [merge tag groups and rules](#merge-tag-groups).
```javascript
unlayer.registerCallback('mergeTagRule', function (data, done) {
// data - contains current mergeTagGroup, current mergeTagRule, list of mergeTags and a context object with the related element id
// done - callback function
// Open custom UI for merge tag groups here
// Once your user is done choosing a merge tag group and rules,
// call the "done" callback function with the new merge tag group.
// Reset the merge tag group and rules
// done({
// mergeTagGroup: null,
// mergeTagRule: null,
// });
done({
mergeTagGroup: 'products',
mergeTagRule: 'repeat',
});
});
```
---
## End-User Identification
Identifying end-users allows developers to associate actions performed in the builder with specific end-users in the host application. This is essential for personalizing the experience, tracking saved blocks, and managing uploads that belong to individual end-users.
## Why Identify End-Users?
Identifying end-users allows the builder to:
- **Save and load personalized content**: Each end-user can have their own set of saved blocks, or file uploads.
- **Provide a seamless experience**: Ensure that the right content is available to the right end-user.
- **Improve tracking**: Keep track of who created, edited, or saved specific files for audit or reporting purposes.
---
## Setting Up End-User Identification
Developers need to provide the builder with information about the current end-user when initializing the builder. This is done by passing the user details through the user property during the initialization process.
Here’s an example of how to initialize the builder with the end-user information:
```javascript
unlayer.init({
user: {
id: 1,
name: 'John Doe',
email: 'john.doe@acme.com',
},
});
```
### User Object Properties
- **id** (required): This is the unique identifier for the end-user within your host application. It could be a number or a string.
- **name** (optional): The name of the end-user, which can be displayed in the interface or logs.
- **email** (optional): The email address of the end-user.
### Why the id is Required
The `id` is the key property that helps the builder associate the user’s actions and saved content with their account. Without it, the builder won’t know which content belongs to which user, making it impossible to offer certain features.
---
## Use Cases for End-User Identification
- **Saved Blocks**: When users save blocks, they are saved under a unique id, making them available only to that specific end-user. The id helps ensure only the right person has access to their saved content. [Learn More](../blocks/user-saved-blocks.md)
- **File Manager**: When users upload images or files, they are saved under a unique id, making them available only to that specific end-user. These files are visible to the end-user in uploads tab. [Learn More](../files-storage/file-manager.md)
- **AI Assistant**: The AI Assistant requires an identified end-user. Without an id, the assistant is hidden in the editor and AI requests are rejected. [Learn More](/ai/assistant/setup)
---
## Best Practices
- **Unique and Consistent IDs**: Ensure that the id you pass is always unique and consistent for each user across different sessions.
- **Optional Fields**: While fields like name and email are optional, it’s good practice to pass them to personalize the end-user experience.
- **Security**: Make sure you review the [Security Settings](./security-settings.md) to setup identity verification. Identity verification prevents third parties from impersonating your logged-in users.
---
## Events & Callbacks
## Editor Ready
This event is called when the editor is ready for use. It waits for custom js, css and fonts to be fully loaded.
```javascript
unlayer.addEventListener('editor:ready', function () {
console.log('editor:ready');
});
```
---
## Design Loaded
This event is called when your design is loaded in the editor.
```javascript
unlayer.addEventListener('design:loaded', function (data) {
// Design is loaded
var json = data.design; // design json
});
```
---
## Design Updated
This event is called when the user changes anything in the editor. This can be used to set up [auto saving](../get-started/load-and-save-designs.md#auto-saving-a-design).
```javascript
unlayer.addEventListener('design:updated', function (data) {
// Design is updated by the user
var type = data.type; // body, row, content
var item = data.item;
var changes = data.changes;
console.log('design:updated', type, item, changes);
});
```
This event is also triggered while text is being edited in the text editor. You can customize the text editor's settings to optimize performance. [Learn More](text-management/trigger-change-event.md)
---
## Image Uploaded
This event is called when an image is uploaded by the user.
```javascript
unlayer.addEventListener('image:uploaded', function (data) {
var image = data.image;
var url = image.url;
var width = image.width;
var height = image.height;
});
```
---
## Custom Fonts
You can add and manage custom fonts to provide a more personalized design experience for your end-users. These fonts will be available for selection in the editor, allowing users to create content that adheres to their branding.
## Usage
You can pass custom fonts configuration during initialization like this:
```javascript
unlayer.init({
fonts: {
showDefaultFonts: true,
customFonts: [
{
label: 'Comic Sans',
value: "'Comic Sans MS', cursive, sans-serif",
},
{
label: 'Lobster Two',
value: "'Lobster Two',cursive",
url: 'https://fonts.googleapis.com/css?family=Lobster+Two:400,700',
weights: [
{ label: 'Regular', value: 400 },
{ label: 'Bold', value: 700 },
],
},
],
},
});
```
In this example default fonts are loaded, and two new fonts are added: a web safe font (Comic Sans) and a Google hosted web font (Lobster Two).
## Parameters
| Parameter | Description | Default |
| :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------ |
| **showDefaultsFonts** | This boolean parameter indicates whether default fonts are loaded in the list or not. If it's set to `false`, only your custom fonts will be loaded. If it's set to `true`, your custom fonts will be loaded along with the default fonts. | `true` |
| **customFonts** | This is an array of objects which contains your custom fonts. These custom fonts could be web safe fonts or hosted web fonts (google, etc). | `[]` |
Each element in **customFonts** can have the following properties:
| Parameter | Description | Example |
| :--------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------ |
| **label** | This is the name of the font that will be shown to the end-users in the dropdown. | `Lobster` |
| **value** | This is the CSS font stack that will be emitted in the HTML. You should provide at least one fallback font to ensure the text is not displayed in an unwanted font family. Make sure you use single quote marks with the font names instead of double quotation marks to maintain a correct syntax. | `'Lobster', cursive` |
| **url**_(optional)_ | This parameter is used only when we work with web fonts. It's important that the URL points to a CSS file with the @font-face properties, and not directly to the font files. Also, CSS must be hosted on HTTPS. | `https://fonts.googleapis.com/css?family=Lobster` |
| **weights**_(optional)_ | This is an array of supported weights for each font. | `[{ label: 'Regular', value: 400 }, { label: 'Bold', value: 700 }]` |
---
## Example
Here's another example where it only shows your custom fonts and not the default fonts. It's a mix of web safe system fonts and Google fonts.

```javascript
unlayer.init({
fonts: {
showDefaultFonts: false,
customFonts: [
{
label: 'Anton',
value: "'Anton', sans-serif",
url: 'https://fonts.googleapis.com/css?family=Anton',
},
{
label: 'Georgia',
value: "Georgia, Times, 'Times New Roman', serif",
},
{
label: 'Helvetica',
value: "'Helvetica Neue', Helvetica, Arial, sans-serif",
},
{
label: 'Lucida',
value: "'Lucida Grande', 'Lucida Sans', Geneva, Verdana, sans-serif",
},
{
label: 'Lato',
value: "'Lato', Tahoma, Verdana, sans-serif",
url: 'https://fonts.googleapis.com/css?family=Lato',
},
],
},
});
```
---
### Grouped Fonts
You can organize fonts into groups using the `fonts` configuration option. Each group has a `label` and an `options` array containing the individual font definitions.
```javascript
unlayer.init({
fonts: {
showDefaultFonts: false,
customFonts: [
{
label: 'Arial',
value: 'arial,helvetica,sans-serif',
},
{
label: 'Brand Fonts',
options: [
{
label: 'Brand Primary',
value: "'Brand Primary', sans-serif",
url: 'https://fonts.googleapis.com/css2?family=Roboto&display=swap',
},
{
label: 'Brand Secondary',
value: "'Brand Secondary', serif",
url: 'https://fonts.googleapis.com/css2?family=Lora&display=swap',
},
],
},
{
label: 'Google Fonts',
options: [
{
label: 'Open Sans',
value: "'Open Sans', sans-serif",
url: 'https://fonts.googleapis.com/css2?family=Open+Sans&display=swap',
},
],
},
],
},
});
```
---
## Default Fonts
This is the complete list of the default fonts in the Unlayer editor and its configuration, including the URL for Google web fonts:
```json
[
{
"label": "Andale Mono",
"value": "andale mono,times"
},
{
"label": "Arial",
"value": "arial,helvetica,sans-serif"
},
{
"label": "Arial Black",
"value": "arial black,avant garde,arial"
},
{
"label": "Book Antiqua",
"value": "book antiqua,palatino"
},
{
"label": "Comic Sans MS",
"value": "comic sans ms,sans-serif"
},
{
"label": "Courier New",
"value": "courier new,courier"
},
{
"label": "Georgia",
"value": "georgia,palatino"
},
{
"label": "Helvetica",
"value": "helvetica,sans-serif"
},
{
"label": "Impact",
"value": "impact,chicago"
},
{
"label": "Symbol",
"value": "symbol"
},
{
"label": "Tahoma",
"value": "tahoma,arial,helvetica,sans-serif"
},
{
"label": "Terminal",
"value": "terminal,monaco"
},
{
"label": "Times New Roman",
"value": "times new roman,times"
},
{
"label": "Trebuchet MS",
"value": "trebuchet ms,geneva"
},
{
"label": "Verdana",
"value": "verdana,geneva"
},
{
"label": "Lobster Two",
"value": "'Lobster Two',cursive",
"url": "https://fonts.googleapis.com/css?family=Lobster+Two:400,700"
},
{
"label": "Playfair Display",
"value": "'Playfair Display',serif",
"url": "https://fonts.googleapis.com/css?family=Playfair+Display:400,700"
},
{
"label": "Rubik",
"value": "'Rubik',sans-serif",
"url": "https://fonts.googleapis.com/css?family=Rubik:400,700"
},
{
"label": "Source Sans Pro",
"value": "'Source Sans Pro',sans-serif",
"url": "https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,700"
},
{
"label": "Open Sans",
"value": "'Open Sans',sans-serif",
"url": "https://fonts.googleapis.com/css?family=Open+Sans:400,700"
},
{
"label": "Crimson Text",
"value": "'Crimson Text',serif",
"url": "https://fonts.googleapis.com/css?family=Crimson+Text:400,700"
},
{
"label": "Montserrat",
"value": "'Montserrat',sans-serif",
"url": "https://fonts.googleapis.com/css?family=Montserrat:400,700"
},
{
"label": "Old Standard TT",
"value": "'Old Standard TT',serif",
"url": "https://fonts.googleapis.com/css?family=Old+Standard+TT:400,700"
},
{
"label": "Lato",
"value": "'Lato',sans-serif",
"url": "https://fonts.googleapis.com/css?family=Lato:400,700"
},
{
"label": "Raleway",
"value": "'Raleway',sans-serif",
"url": "https://fonts.googleapis.com/css?family=Raleway:400,700"
},
{
"label": "Cabin",
"value": "'Cabin',sans-serif",
"url": "https://fonts.googleapis.com/css?family=Cabin:400,700"
},
{
"label": "Pacifico",
"value": "'Pacifico',cursive",
"url": "https://fonts.googleapis.com/css?family=Pacifico"
}
]
```
---
## Font Sizes
To ensure that your templates have a consistent look and feel, you can define preset font sizes for text elements. This allows users to quickly apply standard sizes without manually adjusting them every time.
```javascript
unlayer.init({
features: {
textEditor: {
fontSizes: ['18px', '20px', '30px'],
},
},
});
```
---
## Font Management
Font management feature allows you to control the fonts available within the builder, ensuring consistency across your designs. Whether you’re using standard web fonts or custom fonts, our platform provides flexible options to manage and apply fonts throughout your email, page, or popup templates.
## Key Features

---
## Best Practices
When embedding our builder into your application, it’s important to implement best practices for managing fonts to ensure optimal performance, flexibility, and user experience.
### Font Compatibility for Emails
- **Fallback Fonts**: Since many email clients do not support custom fonts, ensure your application allows users to define fallback fonts when using custom fonts in email templates. This ensures that emails render correctly in all clients, regardless of whether the custom font is supported.
Example of how to suggest fallback fonts to the builder:
```json
{
"label": "CustomFont",
"value": "'CustomFont', Arial, sans-serif"
}
```
- **Pre-defined Web-Safe Fonts**: Offer a set of standard web-safe fonts (e.g., Arial, Helvetica, Times New Roman) as defaults in the font dropdown. This gives end-users reliable, cross-client options without the risk of rendering issues.
- **Limit Custom Fonts for Email Content**: Educate end-users through documentation or guides that custom fonts should be used sparingly in email content, as many email clients don’t support them.
### Custom Fonts
When adding custom fonts, stick to commonly supported formats (.ttf, .woff, .woff2, .otf) or services like Google Fonts, Adobe Fonts, etc. You may also want to check size of font files to prevent performance issues caused by overly large font files.
### Font Size Presets
**Maintain Consistency with Font Sizes**: Predefine a range of font size presets that reflect commonly used sizes (e.g., 12px, 14px, 16px, etc.). This reduces the need for manual adjustments and maintains design consistency for the end-users.
---
## Link Types
Link types refer to the different formats or destinations that a link can point to within a design. Each link type defines a specific kind of action or behavior when a user clicks a button or a link. Our platform supports a variety of link types to accommodate different use cases, allowing you to create links that perform functions beyond just navigating to a web page.
Each link type can be configured with custom properties, such as tracking codes, link text, and link attributes, to match the specific functionality needed for your design. By defining different link types, you can create a more interactive and user-friendly experience for your audience.
## Available Link Types
- **Website Link (URL)**: Standard hyperlink that directs users to a webpage.
- **Email**: Opens the user's default email client to send an email to a specified address.
- **Phone Number**: Initiates a call when clicked on mobile devices, using the "tel:" protocol.
- **SMS**: Opens the default messaging app to send a pre-configured text message.
- **File Download**: Allows users to download a specified file when they click the link.

Each of these generate a URL with required input from the user. For example, `Open Website` asks the user for a URL and Target.

---
## Adding a New Link Type
You can add your own link types. Every link type has the ability to add a URL to **href** or a JavaScript function to the **onClick** method of the link or button HTML tag. This could be a simple static URL, or a URL generated with user input.
You have to define the following parameters for each link type.
### Parameters
| Name | Description |
| :--------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **name** | Unique name for the link type |
| **label** | Display label for the link type |
| **attrs** | An object where we define the link value. For URL based links, it will have **href** and **target**. For JavaScript links, this will have **onClick**. For File downloads, it will have a boolean **download**. |
| **fields** | This is where we will define the input fields that the user can fill to generate the URL. Check the [input field parameters](#input-field-parameters) below. |
### Input Field Parameters
If you need to get input from users to create the URL for your link, this is how you can define the fields.
| Name | Description |
| :------------------ | :----------------------------------------------------------------------------------------------- |
| **name** | Unique name for the input field |
| **label** | Display label for the input field |
| **defaultValue** | Default value for the input field |
| **inputType** | Input type for the field. This is the HTML input type. Examples: `text`, `email`, `number`, etc. |
| **placeholderText** | Placeholder text for the input field |
| **options** | Only required for dropdown. An array of objects containing all the option labels and values. |
| **sortOptions** | Boolean to sort the options by their labels in ascending order. |
---
## Examples
Here are a few examples that demonstrate different link types.
### Static Link
In this example, we will add a simple `https://google.com` static link as an available link type. If a user selects this action, the button will simply open Google in a new tab.

```javascript
unlayer.setLinkTypes([
{
name: 'static_google_link',
label: 'Go to Google',
attrs: {
href: 'https://google.com/',
target: '_blank',
},
},
]);
```
---
### Input Generated Link
In this example, we will ask the user to enter an **email** and choose a **country** to generate the link URL. The link would look like: `https://mycustomlink.com/?email=xyz@email.com&country=usa`
For this, we will have to define these 2 input fields.

```javascript
unlayer.setLinkTypes([
{
name: 'my_custom_link_type',
label: 'Custom',
attrs: {
href: 'https://mycustomlink.com/?email={{email}}&country={{country}}',
target: '_blank',
},
fields: [
{
name: 'email',
label: 'E-mail',
defaultValue: '',
inputType: 'email',
placeholderText: null,
options: [],
},
{
name: 'country',
label: 'Country',
defaultValue: 'usa',
inputType: null,
placeholderText: null,
options: [
{ value: 'usa', label: 'United States' },
{ value: 'other', label: 'Other' },
],
sortOptions: false,
},
],
},
]);
```
---
### JavaScript onClick Link
In this example, we will define a link type that uses JavaScript onClick instead of href. This is useful if you are using our landing page builder.

```javascript
unlayer.setLinkTypes([
{
name: 'my_custom_alert',
label: 'Show Alert',
attrs: {
onClick: 'alert("Hello World")',
},
},
]);
```
---
### File Download Link
```javascript
unlayer.setLinkTypes([
{
name: 'file_download',
label: 'File Download',
attrs: {
href: '{{file}}', // Link destination (file URL)
download: true, // Enables the download attribute
},
fields: [
{
name: 'file',
label: 'File',
defaultValue: undefined,
isMulti: false, // Single file selection
options: [
{
label: 'Open file management...',
value: '',
onClick(_option, _meta, done) {
// Simulate file manager popup to choose a file
const file = prompt('Enter the file name');
if (!file) return;
// Return the selected file to Unlayer
done({ label: 'Optional label here', value: `${file}.zip` });
},
highlight: true,
},
{
type: 'separator',
},
{
label: 'File 1',
value: 'https://website.com/file1.zip',
},
{
label: 'File 2',
value: 'https://website.com/file2.zip',
},
],
sortOptions: false,
},
],
},
]);
```
---
## Enable/Disable Defaults
You can enable or disable the default link types. `sms` link type is disabled by default because it's not supported by most email clients including Gmail, Yahoo and Outlook.
```javascript
unlayer.setLinkTypes([
{
name: 'phone',
enabled: true,
},
{
name: 'email',
enabled: true,
},
{
name: 'sms',
enabled: false,
},
]);
```
---
## Advanced
### Link Attributes
The editor has the ability to add or modify attributes in the link tag. These attributes can be set to support multiple values or a single value at a time. Chosen value(s) can be appended to an existing attribute or added as a new attribute.
#### Tags Example
A common example is appending multiple chosen tags to the `href` attribute in a link tag. In the following example, we will add support for multiple tags in the link editor and append it to the `href` attribute of the link tag.

You can use the following code sample to enable this feature.
```javascript
unlayer.setLinkTypesSharedConfig({
attrs: {
// 'data-tags': '{{tags}}',
'+href': '?tags={{tags}}', // append tags in the href attribute of link
},
fields: [
{
name: 'tags',
label: 'Tags',
defaultValue: [],
isClearable: true, // clear all tags
isCreatable: true, // allow creation of new tags
isMulti: true, // support multiple tags
limit: 2, // limit the number of items that can be selected when isMulti: true (optional)
limitMessage: 'You can only select up to 2 items', // custom message for when limit is reached (optional)
// optional - if creation is enabled
onCreateOption(inputValue, _meta, done) {
// Save or create inputValue in the database (if needed)
const newOption = {
label: inputValue,
value: inputValue,
};
done(newOption);
},
// existing tags
options: [
{
label: 'A',
value: 'a',
},
{
label: 'B',
value: 'b',
},
{
label: 'C',
value: 'c',
},
],
sortOptions: true,
},
],
});
```
---
## Link Management
The builder provides comprehensive tools for handling various types of links within your designs. Whether you’re creating links to websites, emails, phone numbers, or dynamic content, our platform allows for flexible link configuration to suit different use cases.
This section covers the following:
By leveraging the link management options, you can create seamless, engaging, and dynamic experiences for your users, enhancing interactivity and enabling smooth navigation across different types of content.
---
## Special Links
Special links are predefined links that make it easier for the end-user to pick and set commonly used links for their buttons or links.
These could also be dynamic links that your system generates at the time the message is sent, typically because they include the message ID, the recipient's email, or some other variable. One of the most common one is the unsubscribe link.

Here's an example on how to define special links. It also allows nesting.
```javascript
unlayer.init({
specialLinks: [
{
name: 'Manage account',
href: 'https://[my-account]/',
target: '_self',
},
{
name: 'Frequently used',
specialLinks: [
{
name: 'Subscribe',
href: '[subscribe]',
target: '_blank',
},
{
name: 'Unsubscribe',
href: '[unsubscribe]',
target: '_blank',
},
],
},
],
});
```
---
## Localization
You can change the locale used in Unlayer's user interface as part of the configuration parameters passed to the plugin.
The locale value needs to be passed to the initialization code in ISO 639-1 format (example: en-US). The default locale is set to en-US.
---
## Editor configuration
You can pass the locale when initializing the editor. The default is `en-US`.
```javascript
unlayer.init({
...
locale: 'en-US'
});
```
### Overriding Translations
You can override any word or sentence of any language like this:
```javascript
unlayer.init({
...
locale: 'en-US',
translations: {
'en-US': {
"tools.tabs.content": "Content",
"tools.tabs.row": "Row"
}
}
});
```
> You can check all available translations keys here: [https://github.com/unlayer/translations/blob/master/en.json](https://github.com/unlayer/translations/blob/master/en.json)
> If your project uses [`@unlayer/types`](https://www.npmjs.com/package/@unlayer/types), every translation key is part of `UnlayerTranslationKey`, so your editor will autocomplete them as you type.
> Image editor strings live under their own `image_editor.*` prefix. See the [Image Editor → Translations](../files-storage/images/image-editor.md#translations) reference for the full key list.
### Adding A New Language
If the language you want is not available, you can use the same api from above to add it:
```javascript
unlayer.init({
...
locale: 'xx',
translations: {
'xx': {
"tools.tabs.content": "Content",
"tools.tabs.row": "Row",
// ...
}
}
});
```
> You can check all available translations keys here: [https://github.com/unlayer/translations/blob/master/en.json](https://github.com/unlayer/translations/blob/master/en.json)
## Available Languages
Currently, we have built-in translations for the following languages:
| Language | Region | Code |
| :--------- | :------------- | :-------- |
| Arabic | UAE | **ar-AE** |
| Chinese | China | **zh-CN** |
| Chinese | Taiwan | **zh-TW** |
| Czech | Czech Republic | **cs-CZ** |
| Danish | Denmark | **da-DA** |
| Dutch | Netherlands | **nl-NL** |
| English | USA | **en-US** |
| English | Canada | **en-CA** |
| Estonian | Estonia | **et-EE** |
| Farsi | Iran | **fa-IR** |
| Finnish | Finland | **fi-FI** |
| French | France | **fr-FR** |
| French | Canada | **fr-CA** |
| German | Germany | **de-DE** |
| Hungarian | Hungary | **hu-HU** |
| Indonesian | Indonesia | **id-ID** |
| Italian | Italy | **it-IT** |
| Japanese | Japan | **ja-JP** |
| Korean | Korea | **ko-KR** |
| Norwegian | Norway | **no-NO** |
| Polish | Poland | **pl-PL** |
| Portuguese | Brazil | **pt-BR** |
| Portuguese | Portugal | **pt-PT** |
| Russian | Russia | **ru-RU** |
| Spanish | Spain | **es-ES** |
| Swedish | Sweden | **sv-SE** |
| Turkish | Turkey | **tr-TR** |
| Ukrainian | Ukraine | **uk-UA** |
| Vietnamese | Vietnam | **vi-VN** |
---
## Submitting Translations
You can submit new language translations by creating a PR on this GitHub repo: [https://github.com/unlayer/translations](https://github.com/unlayer/translations)
> While your pull request is not merged, please use the editor configuration from above to setup your editor with your desired translations
---
## Security Settings
Unlayer allows developers to enforce **Identity Verification** to enhance the security of the embedded builder. By verifying the identity of the users interacting with the builder, you can prevent unauthorized third parties from impersonating your logged-in users and misusing the builder. This feature is especially important for ensuring that only authenticated users have access to the builder.
We strongly recommend that you enforce identity verification to protect your application and users.
---
## Prerequisites
In order to enable enhanced security and identity verification, you must set up the following first:
- [End-User Identification](./end-user-identification.md)
## Setup Identity Verification
To set up identity verification, you’ll need to generate a secure [HMAC](https://en.wikipedia.org/wiki/Hash-based_message_authentication_code) (Hash-based Message Authentication Code) signature on your server for each logged-in user. This HMAC signature is then passed to Unlayer along with the user’s information when initializing the editor.
## Sending User Object with HMAC Signature
When initializing the Unlayer builder, you need to pass a user object that includes:
- A unique id for the logged-in user
- A securely generated signature (HMAC)
- Optional information such as name and email for the user
Here’s an example of how to initialize the builder with a `user` object:
```javascript
unlayer.init({
user: {
id: 1, // The user's unique ID
signature: 'XXX', // HMAC signature generated on the server
name: 'John Doe', // Optional user name
email: 'john.doe@acme.com', // Optional user email
},
});
```
---
## Generating the HMAC Signature
To ensure security, the HMAC signature must be generated **on your server** using a secret key. The secret key must never be exposed in client-side code or shared publicly. Here’s how to generate the signature using Node.js and the crypto module:
:::warning Project Secret
Your Project Secret is a sensitive piece of information that should never be exposed in your client-side code, repositories, or any place where it could be accessed by third parties. Always store it securely on your server and limit access to it.
:::
```javascript Node.js
const crypto = require('crypto');
const signature = crypto
.createHmac('sha256', '[PROJECT-SECRET]') // secret key (keep safe!)
.update('[USER-ID]')
.digest('hex');
```
```ruby Ruby on Rails
require 'openssl'
OpenSSL::HMAC.hexdigest(
'sha256', # hash function
'[PROJECT-SECRET]', # secret key (keep safe!)
'[USER-ID]' # user's id
)
```
```php PHP
hash_hmac(
'sha256', // hash function
$user->id, // user's id
'[PROJECT-SECRET]' // secret key (keep safe!)
);
```
```python Django (Python 3)
hmac.new(
b'[PROJECT-SECRET]', # secret key (keep safe!)
bytes(request.user.id, encoding='utf-8'), # user's id
digestmod=hashlib.sha256 # hash function
).hexdigest()
```
You can get your project secret from project settings in the Unlayer [console](https://console.unlayer.com).
### Explanation
- **[PROJECT-SECRET]**: This is your project-specific secret key. It must be kept secure and never exposed to the public or in your client-side code.
- **[USER-ID]**: This is the unique ID for the user (for example, id: 1 in the user object).
- The resulting HMAC signature is a cryptographic hash that verifies the identity of the user when passed to Unlayer during initialization.
---
## Why Use Identity Verification?
- **Prevent Impersonation**: Without identity verification, third parties could potentially impersonate your logged-in users and access or modify content in the editor. By requiring a valid HMAC signature, you ensure that only authorized users can interact with the Unlayer editor.
- **Enhanced Security**: This feature ensures that your users’ actions in the builder are authenticated and protected from potential tampering.
---
## Example Workflow
1. **User logs in** to your application.
2. **On the server**, you generate an HMAC signature using the user’s unique ID and your secret key.
3. **Initialize the Unlayer builder** with the user object containing the user’s ID and the generated HMAC signature.
4. Unlayer **verifies the signature** and ensures that the user accessing the editor is authenticated.
---
## Notes
- Do not commit your secret key to any public repositories or client-side code.
- Always generate the HMAC signature server-side to prevent exposure of your secret key.
- Identity verification only affects users interacting with the Unlayer builder within your application, ensuring they are properly authenticated.
---
## Style Guide
This feature helps you create templates that follow a desired brand style. You can configure pre-defined styles for each tool, which can then be applied by your users using a dropdown.
When a tool has a Style Guide applied, its styles cannot be edited, ensuring it maintains the expected appearance.
## Setting up Style Guide
### Enable feature
```javascript
unlayer.init({
features: {
styleGuide: true,
},
});
```
### Defining the Style Guide styles
To define Style Guide styles, use the `unlayer.setStyleGuide({ ... })` method. See the examples below for API implementation details. All tool values are supported inside the `values` field.
#### Button example
##### Button - Default

##### Button - Primary Style

##### Button - Secondary Style

##### Code
```javascript
unlayer.setStyleGuide({
tools: {
button: {
styles: {
primary: {
label: 'Primary Button',
values: {
buttonColors: {
color: '#ffffff',
backgroundColor: '#000000',
hoverColor: '#ffffff',
hoverBackgroundColor: '#000000',
},
border: {
borderTopWidth: '1px',
borderTopStyle: 'solid',
borderTopColor: '#000000',
borderLeftWidth: '1px',
borderLeftStyle: 'solid',
borderLeftColor: '#000000',
borderRightWidth: '1px',
borderRightStyle: 'solid',
borderRightColor: '#000000',
borderBottomWidth: '1px',
borderBottomStyle: 'solid',
borderBottomColor: '#000000',
},
},
},
secondary: {
label: 'Secondary Button',
values: {
buttonColors: {
color: '#000000',
backgroundColor: '#ffffff',
hoverColor: '#000000',
hoverBackgroundColor: '#ffffff',
},
border: {
borderTopWidth: '1px',
borderTopStyle: 'solid',
borderTopColor: '#000000',
borderLeftWidth: '1px',
borderLeftStyle: 'solid',
borderLeftColor: '#000000',
borderRightWidth: '1px',
borderRightStyle: 'solid',
borderRightColor: '#000000',
borderBottomWidth: '1px',
borderBottomStyle: 'solid',
borderBottomColor: '#000000',
},
},
},
},
},
},
});
```
#### Default Styles
##### Initial Style Guide value
In this example, newly dropped buttons would have the `primary` Style Guide style by default:
```javascript
unlayer.init({
tools: {
button: {
properties: {
_styleGuide: {
value: 'primary',
},
},
},
},
});
```
##### Default style without any Style Guide value
In this example, newly dropped buttons without any Style Guide set would have this style by default:
```javascript
unlayer.init({
tools: {
button: {
properties: {
buttonColors: {
editor: {
defaultValue: {
backgroundColor: 'green',
// ...
},
},
},
},
},
},
});
```
#### Row example
##### Row - Default

##### Row - Blue Style

##### Row - Green Style

##### Code
```javascript
unlayer.setStyleGuide({
tools: {
rows: {
styles: {
blue: {
label: 'Blue Row',
values: {
backgroundColor: '#E5F2FF',
},
},
green: {
label: 'Green Row',
values: {
backgroundColor: '#E5FFEC',
},
},
},
},
},
});
```
#### Body example
```javascript
unlayer.setStyleGuide({
tools: {
bodies: {
styles: {
green: {
label: 'Green',
values: {
textColor: '#206154',
backgroundColor: '#BFEDD2',
linkStyle: {
body: true,
linkColor: '#169179',
linkHoverColor: '#169179',
linkHoverUnderline: true,
linkUnderline: true,
},
},
},
},
},
},
});
```
#### Custom Tool example
```javascript
unlayer.setStyleGuide({
tools: {
'custom#my_custom_tool': {
styles: {
green: {
label: 'Green',
values: {
color: '#206154',
},
},
},
},
},
});
```
---
## Tab Management
## What is a Tab?
The side panel in the editor consists of various tabs like Content, Blocks, Body, Images, Uploads, etc. Each tab provides access to unique functions of the editor.

---
## Enable / Disable Tabs
You can enable or disable any tab. For default tabs, the key is the tab name. For custom tabs, you have to prefix your tab name with `custom#`.
```javascript
unlayer.init({
tabs: {
content: {
enabled: true,
},
blocks: {
enabled: false,
},
'custom#my_tab': {
enabled: false,
},
},
});
```
---
## Default Active Tab
By default, the "Content" tab is active when editor loads. You can change it to be any other tab.
```javascript
unlayer.init({
tabs: {
blocks: {
active: true,
},
},
});
```
---
## Reposition Tabs
You can change the default positions of tabs.
```javascript
unlayer.init({
tabs: {
content: {
position: 1,
},
blocks: {
position: 2,
},
'custom#my_tab': {
position: 3,
},
},
});
```
---
## Change Icon
You can change the icon of any tab. Icon can be class name of any [fontawesome icon](https://fontawesome.com/icons?d=gallery), or a URL for your icon's image.
```javascript
unlayer.init({
tabs: {
content: {
icon: 'fa-user',
},
blocks: {
icon: 'https://my.cdn.com/blocks_icon.png',
},
},
});
```
---
## Create a Custom Tab
### Register your Tab
The first step is to register your custom tab using the `registerTab` method.

:::info Custom JavaScript
The following JavaScript has to be passed in the customJS parameter when initializing Unlayer. **registerTab**, and **createPanel** methods are only available there. [Learn more](../customization/appearance/custom-js-css.md).
:::
```javascript
unlayer.registerTab({
name: 'my_tab',
label: 'My Tab',
icon: 'fa-smile',
supportedDisplayModes: ['web', 'email'],
renderer: {
Panel: unlayer.createPanel({
render() {
return 'I am a custom tab.';
},
}),
},
});
```
It needs some configuration options which are explained below:
| Option | Description |
| :------------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **name** | Unique name for your tab |
| **label** | Label that users see in the side panel |
| **icon** | Icon that users see in the side panel. The value can be class name of any [fontawesome icon](https://fontawesome.com/icons?d=gallery), or a URL for your icon's image |
| **position** | Optionally, you can specify the position you want the tab to appear on |
| **supportedDisplayModes** | Which display modes will this tab work in. Default value is `['email', 'web']` |
---
### Example using React
Here's an example that uses a React component.
```javascript
const MyPanel = () => I am a custom tab.;
unlayer.registerTab({
name: 'my_tab',
label: 'My Tab',
icon: 'fa-smile',
supportedDisplayModes: ['web', 'email'],
renderer: {
Panel: MyPanel,
},
});
```
---
## Dev Tab
The Dev Tab is a special tab that appears in the editor's side panel when running in development environments. It provides developers with useful information about the editor configuration, authentication status, and version information.
### When Does the Dev Tab Appear?
The Dev Tab automatically appears in development environments and is hidden in production. This ensures that your end-users never see this developer-focused interface.
### Disable the Dev Tab
If you want to disable the Dev Tab during development, you can do so by setting the `devTab` feature to `false` in your editor configuration:
```javascript
unlayer.init({
features: {
devTab: false,
},
});
```
---
## Clean Paste
This feature allows you to control how content is pasted into the text editor from external sources, ensuring that unwanted formatting is either removed or retained based on your configuration. This is particularly useful for maintaining a consistent design and avoiding issues that can arise from copying content with conflicting styles.
## Configuration
By default, clean pasting is set to “**confirm**,” where the user is prompted to decide whether to keep all formatting or only basic formatting when pasting content. You can configure the behavior of clean pasting using the `unlayer.init()` method within the textEditor settings.
| Value | Description |
| :------------------------ | :-------------------------------------------------------------------------------------- |
| **true** | Clean pasting is turned on. No formatting will be added to the pasted content. |
| **false** | Clean pasting is turned off. All formatting from the pasted content will be preserved. |
| **basic** | Only basic formatting (such as bold, italic, and headings) and structure are preserved. |
| **confirm**(default) | Prompts the user to decide whether to keep all formatting or only basic formatting. |
---
### Enable Clean Paste
```javascript
unlayer.init({
features: {
textEditor: {
cleanPaste: true, // Clean pasting is always on, removing all formatting
},
},
});
```
When `cleanPaste: true`, all formatting is stripped when content is pasted, ensuring that only plain text is inserted.
### Disable Clean Paste
```javascript
unlayer.init({
features: {
textEditor: {
cleanPaste: false, // Disable clean pasting, keep all formatting
},
},
});
```
Setting `cleanPaste: false` preserves all formatting from the source when content is pasted into the text editor.
### Preserving Basic Formatting
```javascript
unlayer.init({
features: {
textEditor: {
cleanPaste: 'basic', // Keep structure and basic formatting only
},
},
});
```
When `cleanPaste: 'basic'`, the editor will keep only basic formatting, such as bold, italic, headings, and structure. More complex formatting (such as colors, font sizes, etc.) will be removed.
### Prompting the User (Default Behavior)
```javascript
unlayer.init({
features: {
textEditor: {
cleanPaste: 'confirm', // Prompt the user to decide whether to clean paste
},
},
});
```
By default, the editor will prompt users when they paste content, asking them whether they want to preserve all formatting or just the basic structure. This gives users more control over the pasted content.
---
## Best Practices
- **Consistent Design**: Enabling clean paste (true or basic) helps maintain a consistent design by preventing unwanted or conflicting styles from external sources from being pasted into the editor.
- **User Control**: The default confirm option is useful when you want to give users flexibility over how content is pasted, allowing them to choose whether to keep or remove formatting.
- **Prevent Styling Issues**: Use clean paste in situations where preserving external formatting could lead to styling issues, particularly when users copy content from word processors or websites with complex styles.
By leveraging this feature, developers can ensure that pasted content is clean and consistent with the intended design while offering flexibility in how formatting is handled.
---
## Custom Buttons
This feature allows developers to add custom buttons to the text editor, providing the ability to extend and enhance the editor’s functionality with advanced controls. Custom buttons can be used for a wide range of purposes, such as inserting predefined text, triggering custom actions, or integrating with third-party services.
## Adding Custom Buttons
You can add custom buttons to the text editor by configuring the `customButtons` array in the `unlayer.init()` method. Each button has several properties that define its name, label, icon, and actions.

```javascript
unlayer.init({
features: {
textEditor: {
customButtons: [
{
name: 'my_button', // Unique identifier for the button
text: 'My Button', // The label that will appear on the button
icon: 'bookmark', // Icon that will appear on the button (optional)
onSetup: () => {}, // Function executed when the button is set up
onAction: (data, callback) => {
console.log(data.text); // Log or manipulate text editor data
callback(data.text + ' Updated'); // Perform action and update the text
},
},
],
},
},
});
```
### Button Attributes
- **name**: A unique identifier for the button, used to reference it programmatically.
- **text**: The label of the button that will be displayed in the editor’s toolbar.
- **icon**: Icon can be a class name of any [FontAwesome icon](https://fontawesome.com/icons?d=gallery), a URL for your icon's image or a custom SVG code.
- **onSetup**: A function that runs when the button is set up in the editor. You can use this function to initialize or prepare the button for use.
- **onAction**: The main action handler that runs when the user clicks the button. It receives two arguments:
- **data**: The current data in the text editor (e.g., text, formatting).
- **callback**: A function used to perform the desired action and update the editor with new content or behavior.
### Custom SVG Icons
You can also use custom SVG code as the icon. Here’s an example:
```javascript
unlayer.init({
features: {
textEditor: {
customButtons: [
{
name: 'my_button',
text: 'My Button',
icon: '', // Insert your custom SVG here
},
],
},
},
});
```
---
## Best Practices
- **Clear Naming and Icons**: Choose meaningful names and icons for your custom buttons to make it clear to end-users what the button does.
- **Action Feedback**: Provide immediate visual feedback when users click a custom button. For example, you can update the text in the editor or show a notification indicating that the action was successful.
- **Limit Complexity**: While custom buttons can be powerful, keep their functionality focused and straightforward to ensure a smooth user experience.
By using this feature, developers can build custom, advanced controls directly into the text editor, allowing for powerful text manipulation and interaction capabilities that go beyond standard editor features.
---
## Emojis
This feature allows users to insert emojis directly into their content using the text editor, adding a fun and expressive element to emails, pages, and other designs. Emojis can enhance communication by conveying emotion or emphasis in a simple, visual way.
## Enable or Disable
By default, the emoji feature is enabled, but you can choose to disable or enable it using the `unlayer.init()` method within the textEditor configuration.
```javascript
unlayer.init({
features: {
textEditor: {
emojis: true, // Enable emoji support in the text editor
},
},
});
```
Setting `emojis: true` allows users to access the emoji picker within the text editor and insert emojis into their content.
---
## Best Practices
- **Context-Appropriate Use**: Emojis can add a fun and engaging element to content, but it’s important to consider the context and audience.
- **Cross-Platform Compatibility**: While emojis are widely supported across devices and platforms, some email clients or devices may not render all emojis correctly.
---
## Inline Font Controls
This feature allows users to change the font family, font size, and text alignment directly within the text editor via inline controls. While these controls provide a quick way to format text, they are not recommended due to the availability of more robust font controls in the right panel, which offers better responsiveness across devices.
By default, inline font controls are disabled. You can enable or disable this feature using the `unlayer.init()` method within the textEditor configuration.
## Enable Inline Font Controls
```javascript
unlayer.init({
features: {
textEditor: {
inlineFontControls: true, // Enable inline font controls in the text editor
},
},
});
```
Setting `inlineFontControls: true` will allow users to adjust font family, size, and alignment directly within the text editor, using inline options.
## Disable Inline Font Controls (Recommended)
```javascript
unlayer.init({
features: {
textEditor: {
inlineFontControls: false, // Disable inline font controls (default behavior)
},
},
});
```
Setting `inlineFontControls: false` disables the inline font controls, encouraging users to manage font settings from the right panel, which provides more comprehensive and device-friendly options.
---
## Best Practices
- **Right Panel Usage**: We recommend keeping inline font controls disabled. The right panel offers a more consistent and responsive way to manage text formatting, including font family, font size, and alignment. This approach ensures that text renders properly across different devices and screen sizes.
- **Consistency Across Devices**: By using the right panel for font controls, you ensure that font settings are applied more uniformly, especially when switching between mobile and desktop views. This makes your design more responsive and adaptable.
- **Avoiding Clutter**: Disabling inline font controls reduces potential clutter in the editor, keeping the design cleaner and encouraging users to focus on using the more powerful formatting options available in the right panel.
---
## When to Enable
While it is generally recommended to use the right panel for font settings, you may consider enabling inline font controls if:
- Users need quick access to font adjustments directly within the editor.
- You’re working in specific cases where users prefer inline formatting over panel-based controls.
By managing this feature, you can decide how users interact with text formatting within the editor. Keeping this feature disabled ensures a more consistent design experience, with better responsiveness across devices, while enabling it can provide quicker access for users who need direct text formatting control.
---
## Text Management
This section allows developers to configure the text editor features available within the builder. These settings enable fine-grained control over text editing functionalities, such as spell checking, tables, font controls, and more, allowing you to customize the text editing experience for your end-users.
This section covers:
By configuring these options, developers can tailor the text editing experience to meet the needs of their users, ensuring both flexibility and control over content creation within the builder.
---
## Spell Checker
This feature provides automatic spelling checks within the text editor, ensuring that end-users can easily identify and correct spelling errors as they create content. This functionality enhances the overall quality of the text, reducing errors and improving readability.
## Enable or Disable
By default, the spell checker is disabled. You can enable or disable the spell checker using the `unlayer.init()` method within the textEditor configuration.
```javascript
unlayer.init({
features: {
textEditor: {
spellChecker: true, // Enable spell checker
},
},
});
```
Setting `spellChecker: true` enables the spell checker in the text editor, automatically underlining any misspelled words.
---
## Best Practices
- **Content Quality**: It’s recommended to keep the spell checker enabled to ensure higher content quality and reduce the chance of typographical errors in emails, pages, or popups.
- **User Experience**: For applications where language precision is important, the spell checker can help users improve their content without needing external tools.
---
## Tables
This feature allows users to create and edit tables directly within the text editor, making it easier to organize and display structured data. This functionality is especially useful for creating layouts, comparison charts, or any content that requires tabular representation.
:::warning
Tables within the text editor are only supported in the deprecated [Text](/builder/latest/tools/text) tool. The [Paragraph](/builder/latest/tools/paragraph#migrating-from-text-tool) tool does not support tables in the text editor. For current table functionality, use the dedicated [Table](/builder/latest/tools/table) tool available on non-legacy plans.
:::
## Enable or Disable
The tables feature is disabled by default. You can enable or disable the ability to insert tables using the `unlayer.init()` method under the textEditor configuration.
```javascript
unlayer.init({
features: {
textEditor: {
tables: true, // Enable tables in the text editor
},
},
});
```
Setting `tables: true` enables the table creation and editing tools in the text editor. Users will be able to insert tables, modify the number of rows and columns, and adjust table formatting.
---
## Best Practices
- **Structured Data**: For content requiring organized information, like pricing grids or data comparisons, enabling tables can significantly improve the readability and layout.
- **Responsive Design**: When using tables, especially in emails, it’s important to ensure that they are designed to be responsive. Emails are often viewed on mobile devices, and large or complex tables may not display well on smaller screens.
- **Email Client Compatibility**: Tables in emails can behave differently across various email clients (such as Gmail, Outlook, etc.). Be mindful of compatibility issues by testing the email templates on different clients. Some email clients, particularly older ones, may not fully support modern table styling or responsive layouts.
- **Overcomplicated Layouts**: When end-users design emails, they can create overly complex tables with too many rows, columns, or nested tables, and this can lead to poor rendering in certain email clients.
---
## Text Direction
This feature enables text direction controls within the text editor, allowing users to switch between left-to-right (LTR) and right-to-left (RTL) text directions.
## Enable or Disable
By default, the text direction feature is disabled. You can enable or disable the text direction controls using the `unlayer.init()` method within the textEditor configuration.
```javascript
unlayer.init({
features: {
textEditor: {
textDirection: true, // Enable text direction controls
},
},
});
```
---
## Trigger Change Event
This feature gives developers more control over how the text editor triggers changes during editing. You can configure the text editor to trigger change events more effectively during text editing and control how frequently those events are fired using a debounce mechanism.
Here’s the API configuration for the new options:
```javascript
unlayer.init({
features: {
textEditor: {
triggerChangeWhileEditing: true, // default: true
debounce: 300, // default: 300 (ms)
},
},
});
```
---
## API Options
- **triggerChangeWhileEditing** (default: true)
- **Description**: This option controls whether the text editor should trigger the `design:updated`event while the end-user is actively editing text. By default, this is set to true, meaning that the event will be fired in real-time as text is being updated.
- **Use Case**: If you’re building an application where real-time updates or auto-saving features are critical, you should keep this option enabled to ensure that changes are consistently saved.
- **debounce** (default: 300ms)
- **Description**: This option allows you to control the debounce time for triggering the `design:updated` event. Debouncing ensures that the event isn’t triggered excessively during rapid typing or frequent text changes. By default, the debounce time is set to 300 milliseconds.
- **Use Case**: You can adjust the debounce value based on the performance needs of your application. A higher value will reduce the frequency of change events (better for performance), while a lower value will make the change detection more responsive (better for real-time feedback).
---
## Example Use Case
If you want to ensure that every change in the text editor is registered and saved without delay, you can configure the text editor to trigger updates with minimal debouncing:
```javascript
unlayer.init({
features: {
textEditor: {
triggerChangeWhileEditing: true,
debounce: 100, // Trigger changes every 100ms during editing
},
},
});
```
In this example:
- `triggerChangeWhileEditing: true` ensures that changes are consistently tracked.
- `debounce: 100` makes the event more responsive, ideal for scenarios where real-time feedback or frequent auto-saving is required.
---
## Notes
- **Performance Considerations**: Lower debounce values can lead to frequent event triggering, which may have performance implications for very large designs or complex layouts. Adjust the debounce time based on the complexity of your design and the performance requirements of your host application.
- **Default Behavior**: By default, both options are set to ensure real-time event handling with reasonable performance, but you can customize these values to fit the needs of your users and application.
---
## Legacy Templates
:::warning
Loading HTML content that includes full document structure (like \ or \ tags) is no longer supported since version **1.249.0**. To continue using this feature, please [lock your editor version](/builder/version-management) to **1.227.0 or lower**.
:::
This feature allows you to seamlessly integrate and manage your existing library of HTML templates within our platform. This version of the editor offers a classic WYSIWYG experience, designed specifically for users who have a collection of legacy HTML templates. By using the builder in classic mode, you can maintain consistency and continue using your old HTML content while still taking advantage of the powerful features our platform provides.
## Key Features
- **Classic WYSIWYG Editor**: A familiar and simple editing environment for users who prefer a basic HTML editor, without the need for advanced drag-and-drop functionality.
- **HTML Compatibility**: Easily import and edit legacy HTML templates, ensuring backward compatibility with your existing content.
- **Unified Experience**: Use the same platform for both new and legacy templates, allowing for a streamlined workflow and consistent template management.
---
In classic mode, there is no JSON and templates are loaded and exported using HTML only.
## Load Design
You can load the legacy design like this.
```javascript
unlayer.loadDesign({
html: 'This is a legacy HTML template.',
classic: true,
});
```
---
## Export Design
The export function will return HTML.
```javascript
unlayer.exportHtml(function (data) {
var html = data.html; // final html
// Do something with the html
});
```
---
## Update Event
```javascript
unlayer.addEventListener('design:updated', function (data) {
var type = data.type; // html:updated
console.log('design:updated', type);
});
```
---
## Full Example
We have a full example of legacy template. [Check it out](https://examples.unlayer.com/web/legacy-template)
---
## Multi-Language Templates
Multi-Language lets a single template hold content in multiple languages. Users switch languages in the editor and translate text, images, and other elements without duplicating the template.
:::info Editor Localization
Multi-Language controls the languages of your **template content**. To translate the editor interface itself (panels, buttons, tool names), see [Localization](../advanced-configuration/localization.md).
:::
## Enable
Disabled by default. Enable it with `features.multiLanguage` — pass `true`, or an object with `enabled: true` plus the available languages (the object form does **not** enable the feature on its own):
```javascript
unlayer.init({
features: {
multiLanguage: {
enabled: true,
languages: [
{ label: 'English', value: 'en_US', default: true },
{ label: 'Spanish', value: 'es_ES' },
{ label: 'Arabic', value: 'ar_AE', rtl: true },
],
},
},
});
```
Each language object supports:
| Property | Type | Required | Description |
| :-------- | :-------- | :------- | :--------------------------------------------------------------------------------- |
| `value` | `string` | Yes | Unique identifier for the language, e.g. `en_US`, `pt_BR` |
| `label` | `string` | No | Display name shown in the language selector. Can be omitted if using translations. |
| `default` | `boolean` | No | Marks the default language. Set it on exactly one language. |
| `rtl` | `boolean` | No | Applies `direction: rtl` to content when this language is selected. |
:::warning Set a default
If no language has `default: true`, the first language in the array is only the _initially selected_ one — no language becomes the default. Edits in every language are then stored as per-language overrides, so untranslated content falls back to each tool's built-in default value instead of the first language's content.
:::
## Switching languages
When enabled, a language selector appears in three places: the editor toolbar, the Body settings tab, and the preview modal header. Switching re-renders the canvas with that language's content:
You can also drive it programmatically after init:
```javascript
// Replace the available languages
unlayer.setLanguages([
{ label: 'English', value: 'en_US', default: true },
{ label: 'Portuguese', value: 'pt_BR' },
]);
// Switch the active language
unlayer.setCurrentLanguage('pt_BR');
```
## Translating language labels
To make the language dropdown follow the user's locale, define keys via the [translations](../advanced-configuration/localization.md) API instead of hardcoding `label`. For each language the editor checks, in order:
1. `languages.{value}` — the `value` lowercased (`languages.en_us` for `en_US`)
2. The `label` as an exact key, or `labels.{label}` in snake_case (`labels.english_us` for `English (US)`) — both skipped when the language has no `label`
3. Falls back to `label` if set, then to `value`
```javascript
unlayer.init({
features: {
multiLanguage: true,
},
translations: {
en: { 'languages.en_us': 'English', 'languages.pt_br': 'Portuguese' },
'pt-BR': { 'languages.en_us': 'Inglês', 'languages.pt_br': 'Português' },
},
});
unlayer.setLanguages([{ value: 'en_US' }, { value: 'pt_BR' }]);
```
:::info Lowercase keys
Translation keys are always lowercase, even when the language `value` contains uppercase characters: for `en_US`, the key is `languages.en_us`.
:::
## Data structure
Content edited in the default language is stored at the root of the element's values. Other languages store only their overrides in a `_languages` object; anything not translated falls back to the default language:
```json
{
"text": "Hello World",
"_languages": {
"es_ES": { "text": "Hola Mundo" },
"fr_FR": { "text": "Bonjour le Monde" }
}
}
```
Multi-Language composes with [device overrides](../customization/editor-behavior/responsive-controls.md): the root `_languages` translates the base (desktop) values, and each device override carries its own `_languages` inside `_override` — so e.g. a background image can vary by device **and** language:
```json
{
"backgroundImage": { "url": "https://example.com/pt_desktop.jpg" },
"_languages": {
"en_US": {
"backgroundImage": { "url": "https://example.com/en_desktop.jpg" }
}
},
"_override": {
"mobile": {
"backgroundImage": { "url": "https://example.com/pt_mobile.jpg" },
"_languages": {
"en_US": {
"backgroundImage": { "url": "https://example.com/en_mobile.jpg" }
}
}
}
}
}
```
## Supported content types
| Content Type | Translatable Properties |
| :------------- | :------------------------------------------------------ |
| Text | Text content, font family |
| Paragraph | Text content, font family |
| Heading | Heading text |
| Button | Button text |
| Image | Source image, alt text |
| Menu | Menu items (text and links) |
| Table | Cell content; header, content, and footer font families |
| Form | Field, label, and button font families |
| Timer | Countdown settings (labels, locale, fonts), alt text |
| Row Background | Background images (for localized visuals) |
Font families being per-language lets you pick a script-appropriate font for each language (e.g. Arabic, Hebrew, or CJK content).
## Exporting in a specific language
Pass `language` to any export method — `exportHtml`, `exportPlainText`, `exportImage`, `exportPdf`, `exportZip` (see [Export HTML](../get-started/export/export-html.md) for all options). Element dimensions (e.g. button width) are recalculated for the specified language's content:
```javascript
unlayer.exportHtml(
function (data) {
var html = data.html;
},
{ language: 'es_ES' },
);
```
If omitted, the export uses the language currently selected in the editor (initially the default language).
---
## Templates

Templates are pre-designed layouts that serve as starting points for creating content such as emails, landing pages, or popups. They provide a structure and design framework, allowing users to quickly customize and build their content without starting from scratch.
### Key Features
- **Reusable**: Templates can be saved and reused, saving time and ensuring design consistency.
- **Customizable**: Users can modify every aspect of a template, from text and images to layouts and styles, to fit their specific goals.
- **Efficient**: Templates reduce the time required to create new content by offering a ready-made structure that users can quickly adapt.
There are two ways you to manage templates in Unlayer. You can either use Unlayer's template manager, or you can save the templates in your own database.
---
## Template Management
Our platform provides two flexible options for managing templates. You can either design and store templates on our servers using the template manager, or manage them locally in your own database.
## Unlayer Template Manager (Easy)
Unlayer provides a template manager where your team members can easily collaborate, create and modify templates. This is available in Library > Templates section of the [developer console](https://console.unlayer.com). Each template is given a unique ID which can be used during initialization or any time after that.
Here's an example on how you can do it **during initialization** by passing a `templateId`.
```javascript JavaScript
unlayer.init({
projectId: 1,
templateId: 123,
});
```
Or, you can do it **after initialization** by calling the following function.
```javascript JavaScript
unlayer.loadTemplate(1); // 1 is the templateId
```
You can also use our [Cloud API](/server/cloud-api/reference) to fetch the templates from your server at any time.
---
## On Your Own Servers (Advanced)
If you want to save the templates on your own servers, that can be easily done. Templates are exactly like regular designs created using our editor. So they can be [saved and loaded](../get-started/load-and-save-designs.md) using the same API.
Once you create the template in Unlayer's template manager, switch to the JSON tab to get its' JSON. You can save this JSON in your database.
After the editor is initialized, you have to pass the template JSON to the `loadDesign` method.
```javascript
var template = {...}; // template JSON
unlayer.loadDesign(template);
```
---
## Which Option to Choose?
- **Unlayer Template Manager** if you prefer to offload the complexity of template storage and focus solely on loading templates. This is ideal for those who want quick integration without managing backend infrastructure.
- **On Your Own Servers** if you need more control over template storage, want to integrate templates with other internal systems, or prefer to maintain full ownership of template data.
---
## Template Permissions
The builder includes an **_Editor Mode_** for developers and administrators who create templates for end-users. This mode allows you to lock or restrict certain parts of a template so that end-users cannot modify them, ensuring critical design elements remain intact while allowing flexibility in other areas.
## Enable Editor Mode
To enable this mode, initialize the builder with the designMode set to 'edit'. This activates additional controls within the builder, specifically in a new "Admin" section at the bottom of the editor panel, which provides granular control over content elements.
```javascript
unlayer.init({
designMode: 'edit', // default value is 'live'
});
```
---
Admin Section Controls
When editor mode is enabled, the "Admin" section in the editor panel provides the following options to control how rows and content blocks behave for end-users:
Selectable: Allows you to control whether the end-user can select the content block for editing.
Draggable: Determines if the end-user can drag the content to reposition it.
Duplicatable: Controls whether the end-user can duplicate the content block.
Deletable: Determines if the end-user can delete the content block.
Hideable: Lets you control whether the end-user can hide the content on specific devices (e.g., mobile or desktop).
Locked: Determines if the end-user can change anything in block. When applied to a Row or Column, this also affects their content.
These controls allow developers or administrators to lock specific parts of the template, ensuring that the end-user cannot accidentally modify or delete essential elements of the design.
---
## Use Case Example
Imagine a scenario where a company has a standard email template with a header, body, and footer. To maintain brand consistency, the header and footer should remain locked and uneditable for end-users, while the body section can be customized with different content.
By creating the template in editor mode, the administrators can:
- Lock the header and footer by disabling **Selectable**, **Deletable**, and **Draggable** options.
- Allow the end-user to only edit the body content, ensuring the core design elements remain intact.
- If you also want to prevent content editing, you can mark either the content or their parent container as **Locked**.
### Locked Row Example
If you mark a Row as **Locked**, here is how it will look like in the editor on `live` mode:
---
## Notes
- The admin section is only available in editor mode (`designMode: 'edit'`)). When the builder is running in `live` mode, end-users will not see these options.
- This feature is ideal for ensuring that essential design elements remain unchanged while still giving end-users the flexibility to customize other areas of the template.
- These settings provide granular control, allowing administrators to create templates with different levels of flexibility for end-users.
---
## Angular Component
## Installation
```bash
npm install angular-email-editor
```
## Module setup
Add `EmailEditorModule` to your application's imports:
```typescript title="app.module.ts"
@NgModule({
imports: [
// ...other imports
EmailEditorModule,
],
})
export class AppModule {}
```
## Basic usage
```typescript title="app.component.ts"
@Component({
selector: 'app-root',
template: `
`,
})
export class AppComponent {
@ViewChild('emailEditor') emailEditor!: EmailEditorComponent;
options = {
projectId: 1234, // replace with your Unlayer Project ID
displayMode: 'email',
features: {
ai: true,
},
};
onReady() {
// Editor is ready. Optionally load a saved design here:
// this.emailEditor.editor.loadDesign(savedDesign);
}
exportHtml() {
this.emailEditor.editor.exportHtml((data: any) => {
const { design, html } = data;
console.log('HTML output:', html);
});
}
saveDesign() {
this.emailEditor.editor.saveDesign((design: any) => {
console.log('Design JSON:', design);
});
}
}
```
If you prefer separate template/HTML files, the same component split looks like:
```html title="app.component.html"
```
## Loading a saved design
```typescript
onReady() {
const savedDesign = /* fetch from your backend */;
this.emailEditor.editor.loadDesign(savedDesign);
}
```
## Methods
Available on `this.emailEditor.editor`:
| Method | Params | Description |
| :------------- | :---------------- | :----------------------------------------------------- |
| **loadDesign** | Object data | Loads a saved design JSON into the editor |
| **saveDesign** | Function callback | Returns the current design JSON via callback |
| **exportHtml** | Function callback | Returns the rendered HTML and design JSON via callback |
## Props
| Name | Type | Description | Default |
| :------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------ |
| **minHeight** | String | Minimum height to initialize the editor with | `500px` |
| **options** | Object | Options passed to the Unlayer editor instance. See the [configuration options](../get-started/installation.md#configuration-options) for everything available. | `{}` |
| **tools** | Object | Configuration for built-in and custom tools | `{}` |
| **appearance** | Object | Configuration for appearance and theme | `{}` |
| **projectId** | Integer | Unlayer Project ID (alternative to passing via `options`) | |
| **ready** | Output | Emits when the editor has finished loading | |
| **loaded** | Output | Emits when the editor instance is created | |
## Example
A full working reference implementation lives in the [`angular-email-editor` demo](https://github.com/unlayer/angular-email-editor/tree/master/src).
---
## React Component
## Installation
```bash
npm install react-email-editor
```
## Basic usage
```jsx title="src/EmailComponent.jsx"
export default function EmailComponent() {
const emailEditorRef = useRef(null);
const onReady = (unlayer) => {
// Editor is ready. Optionally load a saved design here:
// unlayer.loadDesign(savedDesign);
};
const exportHtml = () => {
emailEditorRef.current?.editor.exportHtml((data) => {
const { design, html } = data;
console.log('HTML output:', html);
});
};
const saveDesign = () => {
emailEditorRef.current?.editor.saveDesign((design) => {
console.log('Design JSON:', design);
});
};
return (
);
}
```
Render it from your app:
```jsx title="src/App.jsx"
export default function App() {
return ;
}
```
`onReady` fires once the editor has fully loaded. Inside it, you can call any of the [methods](#methods) below — most commonly `loadDesign` to populate the editor with a saved template.
## TypeScript
`react-email-editor` ships its own types. Pass the editor ref the right shape with `EditorRef`, and type the `onReady` handler with `EmailEditorProps['onReady']`:
```tsx title="src/EmailComponent.tsx"
export default function EmailComponent() {
const emailEditorRef = useRef(null);
const onReady: EmailEditorProps['onReady'] = (unlayer) => {
// unlayer is fully typed here
};
return ;
}
```
## Configuration
Pass editor configuration through the `options` prop. The shape matches the [full `unlayer.init` configuration](../get-started/installation.md#configuration-options):
```jsx
```
## Loading a saved design
```jsx
const onReady = (unlayer) => {
const savedDesign = /* fetch from your backend */;
unlayer.loadDesign(savedDesign);
};
```
## Methods
Available on `emailEditorRef.current.editor`:
| Method | Params | Description |
| :------------- | :---------------- | :----------------------------------------------------- |
| **loadDesign** | Object data | Loads a saved design JSON into the editor |
| **saveDesign** | Function callback | Returns the current design JSON via callback |
| **exportHtml** | Function callback | Returns the rendered HTML and design JSON via callback |
## Props
| Name | Type | Description | Default |
| :--------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------ |
| style | Object | Style for the editor container | `{}` |
| minHeight | String | Minimum height to initialize the editor with | `500px` |
| onLoad | Function | Called when the editor instance is created | |
| onReady | Function | Called when the editor has finished loading | |
| options | Object | Options passed to the Unlayer editor instance. See the [configuration options](../get-started/installation.md#configuration-options) for everything available. | `{}` |
| tools | Object | Configuration for built-in and custom tools | `{}` |
| appearance | Object | Configuration for appearance and theme | `{}` |
## Example
A full working reference implementation lives in the [`react-email-editor` demo](https://github.com/unlayer/react-email-editor/blob/master/demo/src/example/index.tsx).
---
## Vue Component
## Installation
```bash
npm install vue-email-editor
```
## Basic usage (Vue 3 Composition API)
```html title="App.vue"
```
## Vue 2 / Options API
```html title="App.vue"
```
## Nuxt.js
Wrap the editor in `` so it renders only on the client:
```html title="pages/editor.vue"
```
## Configuration
Pass editor configuration through the `options` prop, or via dedicated props for the most common knobs. The `options` shape matches the [full `unlayer.init` configuration](../get-started/installation.md#configuration-options):
```html
```
## Loading a saved design
```javascript
const onReady = () => {
const savedDesign = /* fetch from your backend */;
emailEditor.value.editor.loadDesign(savedDesign);
};
```
## Methods
Available on `this.$refs.emailEditor.editor` (Options API) or `emailEditor.value.editor` (Composition API):
| Method | Params | Description |
| :------------- | :---------------- | :----------------------------------------------------- |
| **loadDesign** | Object data | Loads a saved design JSON into the editor |
| **saveDesign** | Function callback | Returns the current design JSON via callback |
| **exportHtml** | Function callback | Returns the rendered HTML and design JSON via callback |
## Props
| Name | Type | Description | Default |
| :------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------ |
| **minHeight** | String | Minimum height to initialize the editor with | `500px` |
| **options** | Object | Options passed to the Unlayer editor instance. See the [configuration options](../get-started/installation.md#configuration-options) for everything available. | `{}` |
| **tools** | Object | Configuration for built-in and custom tools | `{}` |
| **appearance** | Object | Configuration for appearance and theme | `{}` |
| **locale** | String | Translations locale | `en-US` |
| **projectId** | Integer | Unlayer Project ID (alternative to passing via `options`) | |
## Example
A full working reference implementation lives in the [`vue-email-editor` demo](https://github.com/unlayer/vue-email-editor/tree/master/src).
---
## Custom Blocks
## What is a Custom Block?
Custom blocks allow host applications to define and provide reusable design components for their end-users. These blocks can be pre-designed by your team and then made available for users to drag and drop into their designs, ensuring consistent branding and efficient content creation. Custom blocks can include a variety of elements such as text, images, buttons, and more.

---
## How Custom Blocks Work
Custom blocks are flexible components that can be reused across multiple designs or templates. These blocks can be built with any available [tools](../tools/built-in-tools/overview.md). These blocks are stored and displayed under the **Blocks** tab in the builder, ready to be dragged into new designs.
Each custom block is created with the following information to make it easier to navigate and search the blocks tab.
### Block Category
All blocks are organized under a _category_. This helps users find different blocks easily. For example: **Headers**, **Footers**, **Features**, etc.
### Block Tags
Each block can have comma-separated tags which are searchable and make it easier for users to filter and find different blocks.
---
## Enable or Disable
By default, custom blocks are enabled in the builder. However, developers can control whether custom blocks are available using the following.
```javascript
unlayer.init({
features: {
blocks: false, // Disables custom blocks
},
});
```
This example disables the availability of custom blocks in the editor. However, this does **not** hide the Blocks tab. You can do that using [Tab Management](../advanced-configuration/tab-management.md).
---
## Creating a Custom Block
There are 2 ways to create and load custom blocks.
### **Console**
This is the quickest way to create tons of custom blocks. They are stored on our servers, and automatically loaded when you initialize the editor with the correct `projectId`.
1. Open Unlayer [Console](https://console.unlayer.com).
2. Go to your Project. If you don't have one, create a new one.
3. Go to the Blocks menu under Library.
4. Click New Block
5. Fill the form and click Create Block
---
### **JavaScript API**
If you want to save and load blocks from your own servers, you can load them up using the following API. Here's a code sample.
#### Load Blocks
```javascript
// Load the blocks from your database
let blocks = [
{
/* Block JSON Object */
},
{
/* Block JSON Object */
},
{
/* Block JSON Object */
},
];
unlayer.registerProvider('blocks', function (params, done) {
console.log('blocks provider', params);
done(blocks);
});
```
#### Reload Provider
If you added or removed blocks, you can reload the provider so it reflects in the editor.
```javascript
unlayer.reloadProvider('blocks');
```
#### Events
You can catch events when a block is added, modified or removed by the end-user.
```javascript
unlayer.registerCallback('block:added', function (newBlock, done) {
console.log('block:added', newBlock);
// Save the block to your database here
// and pass the object to done callback.
// Each block should have it's own unique id
done(block);
});
unlayer.registerCallback('block:modified', function (existingBlock, done) {
console.log('block:modified', existingBlock);
// Update the block in your database here
// and pass the updated object to done callback.
done(block);
});
unlayer.registerCallback('block:removed', function (existingBlock, done) {
console.log('block:removed', existingBlock);
// Delete the block from your database here.
done(block);
});
```
---
## Plan Limits
The number of custom blocks you can use is governed by your plan's Custom Blocks
limit. This limit is enforced across **every** way blocks can be loaded:
- Blocks stored in the [Console](https://console.unlayer.com).
- Blocks passed to `unlayer.init({ blocks: [...] })`.
- Blocks returned by a custom blocks provider registered with
`unlayer.registerProvider('blocks', …)`.
- Blocks created through the REST API (`POST /v2/editor/blocks`), which rejects
requests over the limit with a `Blocks limit exceeded` error.
When more blocks are loaded than your plan allows, only the first _N_ (up to your
plan limit) are made available in the editor; the rest are dropped and a warning
is logged to the browser console. Blocks within your plan limit are unaffected.
To raise this limit, upgrade your plan.
---
## Best Practices
- **Efficiency**: Pre-define commonly used sections, such as headers, footers, or call-to-action sections, so end-users can easily reuse them without having to recreate them from scratch.
- **Preview**: Always test and preview custom blocks to ensure they are responsive and display well on different devices and screen sizes.
- **Categorization**: Organize custom blocks into clear categories like “Headers,” “Footers,” “Content,” etc., to make them easy to find for users.
---
## Synced Blocks
## What are Synced Blocks?
Synced Blocks allow users to create and maintain reusable content blocks that stay synchronized across multiple designs. When a Synced Block is updated, those changes become available to all instances of that block. The updates are applied when designs containing those blocks are loaded, but only if the instance hasn't been locally modified (is not "dirty"). This feature is perfect for maintaining consistency across designs where certain elements need to remain identical.
---
## How Synced Blocks Work
When a user creates a Synced Block, the system assigns it a unique sync ID and stores metadata about its synchronization status. The block can then be used across multiple designs, all connected to the original version.
### Sync States and Behavior
Synced Blocks can exist in different states:
1. **Clean State** - Block is synced and unchanged locally
2. **Dirty State** - Block has local modifications that haven't been saved to the synced version
3. **Outdated State** - A newer version of the synced block is available
When changes are made to any instance of the block, users can:
1. **Update the Synced Block** - Save local changes to make them available to all instances when they are loaded
2. **Restore the original** - Discard local changes and restore the block to its last synced version
3. **Convert to Standard Block** - Turn the block into a regular block, breaking the sync connection
### Intelligent Sync Logic
The system uses sophisticated logic to determine when to apply updates:
- **Dirty State Protection** - Local changes are never overwritten automatically. Blocks marked as "dirty" remain unchanged until manually updated or restored.
- **Timestamp Comparison** - Updates are only applied if the synced block is newer than the local version.
- **Auto-save Behavior** - Regular blocks auto-save on changes, but synced blocks require manual confirmation to prevent unintended updates to linked designs.
### Sync Metadata Structure
Synced Blocks maintain their status through several properties:
- **Sync ID** - Unique identifier connecting all instances of the same block
- **Sync Enabled** - Whether the block is currently part of the synchronization system
- **Updated At** - Timestamp indicating when the block was last updated
- **Dirty** - Boolean flag indicating if the block has unsaved local changes
---
## Enable or Disable Feature
Synced Blocks are an advanced feature that requires both feature enablement and a plan that includes the Synced Blocks feature. The feature must be enabled in two places:
### Feature Configuration
Developers can control whether Synced Blocks are available in the editor using the following configuration:
```javascript
unlayer.init({
features: {
syncedBlocks: true, // Enables synced blocks feature
},
});
```
### Plan requirement
Your project's plan must also include the Synced Blocks feature. Both the feature flag and the plan feature must be enabled for synced blocks functionality to be available.
The editor automatically checks both conditions and only shows synced blocks options when both are satisfied.
## Using Synced Blocks
### Creating a Synced Block
Users can create a Synced Block through the Unlayer Console by enabling sync functionality when creating a new block.
To create Synced Blocks in your project:
1. Log in to **[Unlayer Console](https://console.unlayer.com)**
2. Navigate to your project
3. Go to Library > Blocks section
4. Click "New Block"
5. Fill in the block details and enable the sync option
6. The block will be created with sync capabilities enabled
#### JavaScript API
If you want to save and load synced blocks from your own servers, you can use the following API.
When you load a design containing synced blocks, the editor automatically calls your blocks provider with the `syncIds` found in the design.
##### Load Synced Blocks
```javascript
unlayer.registerProvider('blocks', function (params, done) {
console.log('blocks provider called with:', params);
// For regular block loading:
// params = { userId: 123, displayMode: 'email', category: '', search: '' }
// For synced block loading (when design contains sync blocks):
// params = { userId: 123, displayMode: 'email', syncIds: ['sync-id-1', 'sync-id-2'] }
if (params.syncIds) {
// Called when loading a design with synced blocks
// Return only blocks that match the requested syncIds
const syncBlocks = blocks.filter((block) =>
params.syncIds.includes(block.syncMetadata?.id),
);
done(syncBlocks);
} else {
// Called for regular block browsing in the editor
done(blocks);
}
});
```
**Example synced block data structure:**
```javascript
const blocks = [
{
id: 'block-123',
name: 'Header Block',
data: {
/* Your block design data */
},
// Required for synced blocks
syncMetadata: {
id: 'sync-456', // Unique sync identifier
enabled: true, // Must be true for synced blocks
updatedAt: '2024-01-15T10:30:00Z', // Last update timestamp
},
},
];
```
##### Events
```javascript
unlayer.registerCallback('block:modified', function (existingBlock, done) {
console.log('block:modified', existingBlock);
// Update the synced block in your database here
if (existingBlock.syncMetadata?.enabled) {
// Handle synced block update
updateSyncBlockInDatabase(existingBlock)
.then((savedBlock) => {
// IMPORTANT: Update timestamp for proper sync
savedBlock.syncMetadata.updatedAt = new Date().toISOString();
done(savedBlock);
})
.catch((error) => {
console.error('Failed to save sync block:', error);
done(existingBlock); // Fallback to original data
});
} else {
done(existingBlock);
}
});
```
##### Error Handling
```javascript
// Handle provider errors gracefully
unlayer.registerProvider('blocks', function (params, done) {
try {
if (params.syncIds) {
fetchSyncBlocks(params.syncIds)
.then((blocks) => done(blocks))
.catch((error) => {
console.error('Failed to load sync blocks:', error);
done([]); // Return empty array as fallback
});
} else {
fetchAllBlocks()
.then((blocks) => done(blocks))
.catch((error) => {
console.error('Failed to load blocks:', error);
done([]); // Graceful degradation
});
}
} catch (error) {
console.error('Provider error:', error);
done([]); // Always call done() to prevent hanging
}
});
```
### Managing Synced Blocks in the Editor
When a Synced Block is selected in the editor, users will see a special "Synced Block" panel in the property editor with sync management options:
#### Clean State (Unmodified)
When the block hasn't been modified locally:
- Shows sync status information
- Displays last sync timestamp
- Offers option to convert to a standard block (removing sync)
#### Dirty State (Modified)
When the block has local changes that haven't been saved:
- Shows warning about unsaved changes
- Offers three options:
- **Update Block** - Save changes to the synced block (affects all instances)
- **Restore Original** - Discard local changes and restore from synced version
- **Convert to Standard Block** - Remove sync and keep local changes
### Visual Indicators
Synced Blocks are clearly identified throughout the interface:
#### In the Editor
1. **Sync Block Indicator**: Purple badge showing "Synced Block" with sync timestamp
2. **Dirty State Warning**: Visual indicator when block has unsaved changes
3. **Property Panel**: Special "Synced Block" section in the properties panel
#### In Block Libraries
1. **Sync Icon**: Special link icon next to synced blocks in block lists
2. **Sync Metadata**: Shows sync status and last update information
#### For Content Within Synced Blocks
When editing content inside a synced block (but not the block itself), a placeholder message appears directing users to select the entire row to manage sync settings.
## Best Practices
### When to Use Synced Blocks
- **Branding Elements**: Logo blocks, company signatures, or branded sections
- **Legal Content**: Disclaimers, unsubscribe sections, or compliance text
- **Contact Information**: Address blocks, social media links, or contact details
### When NOT to Use Synced Blocks
- **Dynamic Content**: Blocks that frequently change or need customization per design
- **Campaign-Specific Content**: Content that varies between different campaigns or audiences
- **One-off Designs**: Content that won't be reused across multiple designs
### Design Workflow Tips
1. **Plan Ahead**: Identify reusable content before creating blocks
2. **Test First**: Create and test blocks as regular blocks before converting to synced
3. **Communicate Changes**: When updating synced blocks, ensure team members are aware of the impact
4. **Version Control**: Consider creating new synced blocks for major changes rather than updating existing ones
5. **Regular Audits**: Periodically review synced blocks to ensure they're still relevant and being used
### Performance Considerations
- Synced blocks are loaded on-demand when designs are opened
- The system includes intelligent caching to minimize API calls
- Updates only occur when both the feature flag and the plan feature are enabled
- Dirty state tracking prevents unnecessary overwrites and preserves user intent
### Troubleshooting
#### Synced Block Options Not Visible
- Verify the `syncedBlocks` feature is enabled in your configuration
- Ensure your plan includes the Synced Blocks feature
- Check that you're selecting the row-level element, not content within it
#### Changes Not Syncing
- Confirm the block has a valid sync ID and is enabled
- Check if the block is in a "dirty" state (has unsaved local changes)
- Verify timestamp comparison - newer versions may not override older ones
#### Auto-save Not Working
- This is expected behavior for synced blocks
- Synced blocks require manual save confirmation to prevent unintended updates
- Use the "Update Block" button to save changes to all instances
#### Common Development Issues
**Provider never receives syncIds:**
```javascript
// Check the feature flag
unlayer.init({
projectId: 'your-project',
features: {
syncedBlocks: true, // Must be enabled
},
});
```
Also confirm your project's plan includes the Synced Blocks feature — both must be in place for synced blocks to work.
**Sync blocks not updating across designs:**
- Ensure `block:modified` callback updates `syncMetadata.updatedAt`
- Check that timestamp format is ISO string: `new Date().toISOString()`
- Verify the updated block is returned with correct `syncMetadata.id`
**Editor hangs or freezes:**
- Always call `done()` in your provider, even on errors
- Implement timeouts for async operations
- Use `done([])` as fallback for errors
**Memory leaks with many sync blocks:**
- Implement proper caching with expiration
- Clear old cache entries periodically
- Avoid storing large objects in global scope
**Debug logging:**
```javascript
// Enable comprehensive logging
unlayer.registerProvider('blocks', function (params, done) {
console.log('Provider called:', {
timestamp: new Date().toISOString(),
params,
hasSyncIds: !!params.syncIds,
syncIdCount: params.syncIds?.length || 0,
});
// Your provider logic...
done(blocks);
});
```
---
## User Saved Blocks
This feature allows end-users to save any block of content they’ve created so it can be reused in their designs. This is particularly useful for maintaining design consistency and speeding up content creation. These blocks are saved within the builder’s environment and can be inserted into any design with just a few clicks. Developers can enable and configure this feature for end-users in their applications.
---
## Enable or Disable Feature
### Prerequisites
In order for this feature to work, you must have set up [End-User Identification](../advanced-configuration/end-user-identification.md) in the builder to correctly identify and associate a saved block with a user.
- [Setup End-User Identification](../advanced-configuration/end-user-identification.md)
### Enable Custom Blocks
User saved blocks is a sub-feature of [Custom Blocks](./custom-blocks.md). By default, custom blocks are enabled in the builder. The **save button** for blocks will automatically appear once the user is identified and this feature is enabled.
You can enable or disable this feature using the following example:
```javascript
unlayer.init({
features: {
blocks: true,
},
});
```
---
## How It Works
Users can save their blocks by clicking the save button at the bottom right of a selected block.

Once they click save, they will be asked a **Category Name** and optional **Tags**. This helps organize the blocks and make them searchable.

---
## File Manager
The **File Manager** feature in the builder allows end-users to upload and manage their own images in a uploads tab. It can also be configured to display images stored on your server.
---
## Enable / Disable Feature
### Prerequisites
Before using the File Manager, you need to set up [End-User Identification](../advanced-configuration/end-user-identification.md) in the builder to correctly identify and associate uploaded images with a specific end-user. This ensures that images uploaded or managed within the builder are tied to the correct user account.
- [Setup End-User Identification](../advanced-configuration/end-user-identification.md)
### Enable / Disable File Manager
If you do not wish to use the File Manager, you can easily disable it by setting the `userUploads` feature to false during the builder’s initialization.
```javascript
unlayer.init({
features: {
userUploads: {
enabled: true,
},
},
});
```
---
## Search Uploads
The File Manager also has the ability for end-users to search their uploaded files easily. This feature is enabled by default but you can choose to disable search if that's what you prefer.
```javascript
unlayer.init({
features: {
userUploads: {
enabled: true,
search: true,
},
},
});
```
---
## Custom Database
If your users’ images are already stored in your database, you can configure the File Manager to display those images by using the `unlayer.registerProvider` API.
Here is an example that shows how to load images from your server and display them in the file manager:
| Attribute | Description |
| :-------------- | :---------------------------------------------------- |
| **id** | A unique identifier for the image. |
| **location** | The URL where the image is stored. |
| **width** | Image width in pixels. |
| **height** | Image height in pixels. |
| **contentType** | MIME type of the image (e.g., image/png). |
| **source** | Set as `user` to indicate it’s a user-uploaded image. |
| **size** | Optional. File size of the image |
You can check out a [full example](https://examples.unlayer.com/media/user-uploads) using custom database to load user uploaded images.
```javascript
unlayer.registerProvider('userUploads', function (params, done) {
// Load images from your server here...
var images = [
{
id: Date.now() + i,
location: 'https://picsum.photos/id/1/500',
width: 500,
height: 500,
contentType: 'image/png',
source: 'user',
},
{
id: Date.now() + i,
location: 'https://picsum.photos/id/2/500',
width: 500,
height: 500,
contentType: 'image/png',
source: 'user',
},
];
done(images);
});
```
---
### Saving Uploads
If you want to save the reference to uploaded images from the builder to a custom database so you can send it to the File Manager, you will have to use the `image:uploaded` event. You can do it like this:
```javascript
unlayer.addEventListener('image:uploaded', function (data) {
var image = data.image;
var url = image.url;
var width = image.width;
var height = image.height;
// Save image info to your custom database here
});
```
---
### Pagination
The File Manager supports pagination to efficiently load and display large collections of images. To enable pagination, provide the necessary attributes when returning the images from your server.
| Attribute | Description |
| :---------- | :--------------------------------------------------------------------- |
| **page** | The current page number being requested. |
| **perPage** | The number of images to display per page. |
| **total** | The total number of images available. |
| **hasMore** | A boolean indicating whether more images are available for pagination. |
```javascript
unlayer.registerProvider('userUploads', function (params, done) {
var page = params.page || 1; // Current page number
var perPage = params.perPage || 20; // Number of images per page
var total = 100; // Total number of images available
var hasMore = total > page * perPage; // Check if there are more images to load
// Load images for the current page from your server here...
var images = [];
// Return the images along with pagination data
done(images, { hasMore, page, perPage, total });
});
```
---
### Searching
The File Manager supports searching to efficiently load and display large collections of images. To enable searching, provide the necessary attributes when returning the images from your server.
```javascript
unlayer.registerProvider('userUploads', function (params, done) {
var page = params.page || 1; // Current page number
var perPage = params.perPage || 20; // Number of images per page
var total = 100; // Total number of images available
var hasMore = total > page * perPage; // Check if there are more images to load
var searchText = params.searchText; // The search term
// Load images that match the search term, for the current page from your server here...
var images = [];
// Return the images along with pagination data
done(images, { hasMore, page, perPage, total });
});
```
---
### Handling Deletions
End-users can delete their uploaded images from the File Manager. To handle this, you can use the `unlayer.registerCallback` method to register a callback for the image deletion event. This allows your application to remove the image from your server and synchronize the changes with the builder.
```javascript
unlayer.registerCallback('image:removed', function (image, done) {
// image will include id, userId and projectId
console.log(image);
// call this when image has been deleted
done();
});
```
The image object contains:
- **id**: The unique identifier of the image.
- **userId**: The ID of the end-user who uploaded the image.
- **projectId**: The ID of your project
Once the image is deleted from your server, call the `done()` function to notify the builder that the image has been removed.
---
## Amazon S3
We have file storage support built-in with Amazon S3 that can be configured without any coding. Follow these steps to set up your S3 file storage.
## Create S3 Bucket
If you already have a S3 bucket, skip this step.
The first step is to create a bucket in your AWS S3 console. You need to uncheck "'**Block all public access**" for S3 storage.

---
## Create IAM User
If you already have access key id and secret access key, skip this step.
Go to your AWS IAM console and create a new user with **Programmatic Access** so you can get the access key id and secret access key.
---
## Attach Policy and Permissions
If possible, attach the **AmazonS3FullAccess** policy to the user. However, if you want to give a minimum set of permissions, use the following policy:
Make sure to replace `BUCKET-NAME` with your actual bucket name.
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": [
"s3:GetBucketPublicAccessBlock",
"s3:PutObject",
"s3:GetObject",
"s3:PutBucketPublicAccessBlock",
"s3:GetBucketCORS",
"s3:ListBucket",
"s3:PutBucketCORS",
"s3:GetBucketAcl",
"s3:GetBucketLocation",
"s3:PutObjectAcl",
"s3:PutBucketTagging"
],
"Resource": ["arn:aws:s3:::BUCKET-NAME/*", "arn:aws:s3:::BUCKET-NAME"]
},
{
"Sid": "VisualEditor1",
"Effect": "Allow",
"Action": "s3:ListAllMyBuckets",
"Resource": "*"
}
]
}
```
---
## Confirm Public Access is Not Blocked
Your S3 Bucket must have public access enabled so users can access the uploaded images.
- Go to S3 in AWS Console
- Select your S3 Bucket
- Click **Edit public access settings**
- Uncheck **Block all public access** if it's checked
- Update the **Object Ownership** on your bucket to be Object Writer.
- Click **Save**

---
## Enable CORS for your S3 Bucket
Your S3 Bucket must have CORS (cross-origin resource sharing) enabled. Follow these steps:
- Go to your S3 Bucket in AWS Console
- Click the **Permissions** tab
- Click the **CORS Configuration** button
- Add the following in the configuration editor
```json
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
"AllowedOrigins": ["https://*.unlayer.com"],
"ExposeHeaders": []
}
]
```
```xml
https://*.unlayer.comGETHEADPUTPOST
```
---
## Add Credentials in your Unlayer Project
Simply enter your information of S3 storage against your project. For this:
- Login to Unlayer dashboard
- Select your Project
- Click **Settings** in the navigation bar and select the Storage tab
- Click the **Add Storage** button
- Enter your **Access Key Id, Secret Access Key, and Bucket**
- Check **Primary Storage** at the end

---
## Test your File Storage
Click the **Test Storage** button and see if all the items are working properly. If any of these are red, the storage will not work. In that case, please make sure all permissions are properly attached and the bucket exists in the correct region.

In some cases, we attempt to fix certain permissions so you can try clicking Test Storage multiple times.
---
## Connect your CDN (Optional)
You can connect your CDN (content delivery network) such as Cloudfront (or any other) with your storage. It will add caching and load images faster.
Note: Your CDN must be set up to access your S3 Bucket.
- Login to Unlayer dashboard
- Select your Project
- Click **Settings** in the navigation bar and select the **Storage** tab
- Choose your connected storage
- In **URL Prefix**, add the root URL to your CDN such as `https://cdn.mydomain.com/`
- Optionally, you can also add a path prefix if the URL path also changes
That's it. You are all set 😀
---
## Troubleshooting
If your files are not being uploaded to your S3 bucket correctly, check these:
1. Make sure the storage is marked as **Primary**.
2. You must pass **projectId** when initializing the editor.
```javascript
```
3. Make sure the domain you are running the editor on is added under **Allowed Domains** in your project settings > Embed.
---
## Custom File Storage
:::caution Who needs to do this?
This is only required if you want to host the files somewhere other than Amazon S3. Amazon S3 is supported out of the box and requires no code. You can simply add your AWS Keys and Bucket info under project settings.
:::
By default, we have file storage support built-in with Amazon S3 that can be configured without any coding. If you need to store files somewhere other than Amazon S3 such as your local data center or your own cloud, it can be done by the following way.
## Image Upload Callback
Register this callback function to handle image uploads.
```javascript
unlayer.registerCallback('image', function (file, done) {
// Handle file upload here
});
```
The function returns 2 parameters. We'll call them file and done.
| Parameter | Type | |
| :-------- | :------- | :----------------------------------------------------------------------------------- |
| **file** | Object | Contains an array, `attachments`. |
| **done** | Function | This is the callback function to update progress bar and handling upload completion. |
## Updating Progress Bar
You can call the done callback and pass an object with progress parameter containing the percentage of completion.
```javascript
unlayer.registerCallback('image', function (file, done) {
// File upload code goes here
done({ progress: 10 }); // Updates the progress bar to 10%
});
```
## Finishing Upload
Once the upload has finished, you have to pass the URL of the image to Unlayer.
```javascript
unlayer.registerCallback('image', function (file, done) {
// File upload code goes here
done({ progress: 100, url: 'URL OF THE FILE' });
});
```
## Aborting or Failing an Upload
If your upload cannot complete — for example, the file fails a custom validation check, your storage backend returns an error, or the user cancels — you can reset the upload state instead of passing a URL. Pass `abort` or `error` to `done`:
| Signal | Result |
| :---------------------------------- | :------------------------------------------------------------------------------ |
| `done({ abort: true })` | Silently resets the upload back to its default state, with no error shown. |
| `done({ error: true })` | Resets the upload and shows a generic "Upload Failed" state. |
| `done({ error: 'Custom message' })` | Resets the upload and shows the "Upload Failed" state with your custom message. |
Use `abort` when the user backs out or you handle the error yourself (e.g. in your own modal), and `error` when you want the builder to surface the failure.
```javascript
unlayer.registerCallback('image', function (file, done) {
// Reject unsupported file types with your own validation
if (!isSupportedImage(file.attachments[0])) {
done({
error: 'Only JPG, PNG, GIF, SVG, or WebP files under 5 MB are supported.',
});
return;
}
// ... upload logic, then done({ progress: 100, url: '...' })
});
```
```javascript
unlayer.registerCallback('image', function (file, done) {
showMyCustomErrorModal(); // you handle the error UI yourself
done({ abort: true }); // just reset the builder's upload state
});
```
:::note
Both `abort` and `error` are optional and fully backward compatible — existing callbacks that pass `{ url }` are unaffected.
:::
## Full Sample Code
We will use **fetch** to upload the file in this sample code. We'll post it to **/uploads** on our server. The following is a complete sample code that handles a file upload.
```javascript
unlayer.registerCallback('image', function (file, done) {
var data = new FormData();
data.append('file', file.attachments[0]);
fetch('/uploads', {
method: 'POST',
headers: {
Accept: 'application/json',
},
body: data,
})
.then((response) => {
// Make sure the response was valid
if (response.status >= 200 && response.status < 300) {
return response;
} else {
var error = new Error(response.statusText);
error.response = response;
throw error;
}
})
.then((response) => {
return response.json();
})
.then((data) => {
// Pass the URL back to Unlayer to mark this upload as completed
done({ progress: 100, url: data.filelink });
});
});
```
---
## File Storage
By default, we support Amazon S3 for storing your files and images. It can be easily set up from the dashboard. You can also connect to a custom storage to save files in your cloud.
Check the following resources to set up your file storage.
---
## Custom Image Library
:::caution Not Recommended
This will take full control of all **Upload Image** buttons in the editor. If you want to give users a way to pick their uploaded images, try [**File Manager**](../file-manager.md) which puts uploaded images in the Uploads tab.
:::
If you want to take full control of the image uploading and management, you can use the following callback.
```javascript
unlayer.registerCallback('selectImage', function (data, done) {
// Open your image library
// Once a user picks an image, call the done function with URL
done({ url: 'IMAGE URL GOES HERE' });
});
```
---
## Full Example

You can try the full example of custom image library: [Custom Image Library Example](https://examples.unlayer.com/media/custom-media-library)
---
## Image Editor
We have a built-in image editor that can be used to resize, crop or apply effects to images. It's enabled by default but you can disable it if you want. You can also access it by double-clicking on any image.
```javascript
unlayer.init({
features: {
imageEditor: {
enabled: false,
},
},
});
```
The editor ships a full tool set: **Crop** (with rotate in 90° steps, flip, a straighten slider for fine-angle rotation, and a corner-rounding radius slider for rounded corners, all built into the same tool), **Resize**, **Draw** (brush color/type/size), **Text** (one-tap style presets grouped into Basic, Handwriting, and Effects, a bundled font library picked from a dropdown that previews each face, and font, color, background, outline, and shadow controls; double-click the text on the canvas to edit it), **Shapes** (circles, rectangles, arrows, stars, and more, grouped into Filled, Outline, and Gradient palettes with search, plus drag/resize/rotate, flip, duplicate, and per-shape color, opacity, outline, and shadow controls), **Stickers** (a bundled sticker library — emoticons, doodles, landmarks, and more — with search and per-category browsing; single-color stickers in the doodles, landmarks, beach, and transportation categories can be recolored and given an outline or shadow after adding), **Frame** (9 preset frames with an adjustable size and a color picker for the basic frame), and **Filters** (one-tap presets like Sepia, Vintage, or Polaroid plus adjustment sliders for brightness, contrast, saturation, vibrance, hue, gamma, blur, pixelate, and noise), alongside the AI Assistant. When the AI Assistant is available it leads the tool rail and its chat panel opens automatically when the editor loads.
The tools appear as a vertical tab rail with a properties panel that opens beside it when a tool is selected. Changes persist automatically — closing a tool's panel, switching to another tool, or saving commits its pending edits, and undo/redo reverts or restores them. There is no separate Apply/Cancel step. The AI Assistant behaves like any other tool — its chat opens in the same panel slot. By default the rail docks to the same side as the builder's own tools panel (`appearance.panels.tools.dock`, which defaults to the right edge), so the image editor stays visually consistent with the rest of the editor; set `dock` to `'left'` or `'right'` to override it for the image editor specifically.
Text, shapes, stickers, and drawings are a **live, re-editable layer** on top of the photo: they stay selectable after you switch tools or close a panel. Click an object on the canvas at any time to re-select it — its tool reopens with the object's current styling so you can move, resize, recolor, reorder (bring to front/forward, send backward/to back), or delete it from the floating toolbar above the selection. The photo underneath is the only raster surface; the layers are flattened onto it when you export or save the image, send it to the AI Assistant, or click the **Flatten layers** toolbar button (which bakes them in as a single undoable step). Most edits keep the object layer live: **resize** scales the layers to match, an **axis-aligned crop** shifts them with the photo, and **filters/adjustments** apply to the photo only (overlaid objects keep their own colors). A few transforms can't be applied to vector objects exactly, so they bake (rasterize) the object layer into the photo and it becomes part of the image: **rotate, flip, and the straighten slider** (all inside the Crop tool). Undo restores the live layer in one step.
```javascript
unlayer.init({
features: {
imageEditor: {
dock: 'left', // 'left' | 'right'; omit to follow appearance.panels.tools.dock
},
},
});
```
### Pinning the image editor version
By default the editor always loads the **latest** image editor, so improvements and fixes reach your editor automatically. If you need a specific, reproducible build — for example to match a version you tested against or to stage a change — pin it with `version`. An unknown or unpublished version transparently falls forward to the latest, so a stale pin never breaks image editing.
```javascript
unlayer.init({
features: {
imageEditor: {
version: '1.500.0', // pin an exact published bundle; omit to always use the latest
},
},
});
```
### Image Editor Tools
You can also choose to disable any of the image editor tools. By default, all of these tools are enabled: Filter, Resize, Crop, Draw, Text, Shapes, Stickers, and Frame. Rotate, flip, and rounded corners all live inside the Crop tool (disabling `corners` hides the corner-radius section).
Here's an example if you want to disable the **Resize** tool in the image editor.
```javascript JavaScript
unlayer.init({
features: {
imageEditor: {
tools: {
resize: false,
},
},
},
});
```
### Customize Tool Icons
Each tool entry also accepts an object form so you can customize its icon while keeping the tool enabled. The `icon` field accepts a FontAwesome icon name (with or without the `fa-` prefix), an image URL (`http(s):`, `data:`, or absolute path), or inline SVG markup (must start with `' },
resize: { enabled: false },
},
},
},
});
```
When the icon string cannot be resolved, the editor falls back to the default icon for that tool.
### Translations
The image editor ships with built-in translations for the locale you pass to `unlayer.init({ locale })` and falls back to English when a locale is not yet available. You can override any string — or supply translations for a brand-new locale — through the same `translations` config you use for the rest of the editor (see [Localization](../../advanced-configuration/localization.md)). All image editor keys are grouped under the `image_editor.*` prefix.
> If your project uses [`@unlayer/types`](https://www.npmjs.com/package/@unlayer/types), every translation key (and the rest of the `unlayer.init` config) is fully typed, so your editor will autocomplete the keys below as you write them.
```javascript JavaScript
unlayer.init({
locale: 'fr-FR',
translations: {
'fr-FR': {
'image_editor.toolbar.save': 'Enregistrer',
'image_editor.tools.filter': 'Effets',
'image_editor.ai_assistant.placeholder':
'Décrivez ce que vous souhaitez modifier…',
},
},
});
```
All image editor translation keys
#### Toolbar
| Key | Default |
| :----------------------------------- | :------------- |
| `image_editor.toolbar.save` | Save |
| `image_editor.toolbar.cancel` | Cancel |
| `image_editor.toolbar.undo` | Undo |
| `image_editor.toolbar.redo` | Redo |
| `image_editor.toolbar.flatten` | Flatten layers |
| `image_editor.toolbar.zoom_in` | Zoom in |
| `image_editor.toolbar.zoom_out` | Zoom out |
| `image_editor.toolbar.fit_to_screen` | Fit to screen |
| `image_editor.toolbar.show_chat` | Show chat |
| `image_editor.toolbar.hide_chat` | Hide chat |
| `image_editor.toolbar.close` | Close |
#### Side nav tools
| Key | Default |
| :---------------------------- | :------- |
| `image_editor.tools.filter` | Filter |
| `image_editor.tools.crop` | Crop |
| `image_editor.tools.resize` | Resize |
| `image_editor.tools.draw` | Draw |
| `image_editor.tools.text` | Text |
| `image_editor.tools.shapes` | Shapes |
| `image_editor.tools.stickers` | Stickers |
| `image_editor.tools.frame` | Frame |
| `image_editor.tools.corners` | Corners |
#### Inline actions, filters, and object labels
| Key | Default |
| :---------------------------------------------- | :------------------- |
| `image_editor.actions.reset` | Reset |
| `image_editor.crop.aspect_free` | Free |
| `image_editor.crop.aspect_original` | Original |
| `image_editor.crop.aspect_square` | Square |
| `image_editor.crop.aspect_ratio` | Aspect ratio |
| `image_editor.crop.rotate_flip` | Rotate & flip |
| `image_editor.crop.straighten` | Straighten |
| `image_editor.resize.width` | Width |
| `image_editor.resize.height` | Height |
| `image_editor.resize.lock_aspect` | Lock aspect ratio |
| `image_editor.rotate.rotate_left` | Rotate left |
| `image_editor.rotate.rotate_right` | Rotate right |
| `image_editor.rotate.flip_horizontal` | Flip horizontal |
| `image_editor.rotate.flip_vertical` | Flip vertical |
| `image_editor.arrange.bring_to_front` | Bring to front |
| `image_editor.arrange.bring_forward` | Bring forward |
| `image_editor.arrange.send_backward` | Send backward |
| `image_editor.arrange.send_to_back` | Send to back |
| `image_editor.draw.brush` | Brush |
| `image_editor.draw.color` | Color |
| `image_editor.draw.type` | Type |
| `image_editor.draw.size` | Size |
| `image_editor.draw.brush_pencil` | Pencil |
| `image_editor.draw.brush_eraser` | Eraser |
| `image_editor.draw.brush_circle` | Circle |
| `image_editor.draw.brush_spray` | Spray |
| `image_editor.draw.brush_diamond` | Diamond |
| `image_editor.draw.brush_vline` | V Line |
| `image_editor.draw.brush_hline` | H Line |
| `image_editor.draw.brush_square` | Square |
| `image_editor.draw.custom_color` | Custom color |
| `image_editor.shapes.fill` | Fill |
| `image_editor.shapes.transparent` | Transparent |
| `image_editor.shapes.color` | Color |
| `image_editor.shapes.opacity` | Opacity |
| `image_editor.shapes.outline` | Outline |
| `image_editor.shapes.shadow` | Shadow |
| `image_editor.shapes.outline_width` | Width |
| `image_editor.shapes.shadow_blur` | Blur |
| `image_editor.shapes.shadow_offset_x` | Offset X |
| `image_editor.shapes.shadow_offset_y` | Offset Y |
| `image_editor.shapes.duplicate` | Duplicate |
| `image_editor.shapes.delete` | Delete |
| `image_editor.shapes.circle` | Circle |
| `image_editor.shapes.rectangle` | Rectangle |
| `image_editor.shapes.triangle` | Triangle |
| `image_editor.shapes.ellipse` | Ellipse |
| `image_editor.shapes.curved_arrow` | Curved arrow |
| `image_editor.shapes.arrow` | Arrow |
| `image_editor.shapes.line` | Line |
| `image_editor.shapes.star` | Star |
| `image_editor.shapes.decagon` | Decagon |
| `image_editor.shapes.shield` | Shield |
| `image_editor.shapes.filled` | Filled |
| `image_editor.shapes.gradient` | Gradient |
| `image_editor.shapes.search` | Search ... |
| `image_editor.shapes.more` | More (\{count\}) |
| `image_editor.shapes.less` | Less |
| `image_editor.shapes.empty` | No shapes found |
| `image_editor.text.new` | New text |
| `image_editor.text.default_text` | Double click to edit |
| `image_editor.text.font` | Font |
| `image_editor.text.size` | Size |
| `image_editor.text.style` | Style |
| `image_editor.text.align` | Align |
| `image_editor.text.bold` | Bold |
| `image_editor.text.underline` | Underline |
| `image_editor.text.strikethrough` | Strikethrough |
| `image_editor.text.italic` | Italic |
| `image_editor.text.align_left` | Align left |
| `image_editor.text.align_center` | Align center |
| `image_editor.text.align_right` | Align right |
| `image_editor.text.background` | Background |
| `image_editor.text.more` | More (\{count\}) |
| `image_editor.text.less` | Less |
| `image_editor.text.group.basic` | Basic |
| `image_editor.text.group.handwriting` | Handwriting |
| `image_editor.text.group.effects` | Effects |
| `image_editor.text.preset.heading` | Heading |
| `image_editor.text.preset.subheading` | Subheading |
| `image_editor.text.preset.body` | Body |
| `image_editor.text.preset.marker` | Marker |
| `image_editor.text.preset.script` | Script |
| `image_editor.text.preset.bubbles` | Bubbles |
| `image_editor.text.preset.sketch` | Sketch |
| `image_editor.text.preset.meme` | Meme |
| `image_editor.text.preset.outline` | Outline |
| `image_editor.text.preset.highlight` | Highlight |
| `image_editor.text.preset.shadow` | Shadow |
| `image_editor.text.preset.neon` | Neon |
| `image_editor.text.preset.typewriter` | Typewriter |
| `image_editor.corners.radius` | Radius |
| `image_editor.stickers.search` | Search ... |
| `image_editor.stickers.more` | More (\{count\}) |
| `image_editor.stickers.less` | Less |
| `image_editor.stickers.empty` | No stickers found |
| `image_editor.stickers.category.emoticons` | Emoticons |
| `image_editor.stickers.category.doodles` | Doodles |
| `image_editor.stickers.category.landmarks` | Landmarks |
| `image_editor.stickers.category.beach` | Beach |
| `image_editor.stickers.category.bubbles` | Bubbles |
| `image_editor.stickers.category.clouds` | Clouds |
| `image_editor.stickers.category.stars` | Stars |
| `image_editor.stickers.category.transportation` | Transportation |
| `image_editor.frame.presets` | Presets |
| `image_editor.frame.adjust` | Adjust |
| `image_editor.frame.size` | Size |
| `image_editor.frame.color` | Color |
| `image_editor.frame.basic` | Basic |
| `image_editor.frame.pine` | Pine |
| `image_editor.frame.oak` | Oak |
| `image_editor.frame.rainbow` | Rainbow |
| `image_editor.frame.grunge1` | Grunge 1 |
| `image_editor.frame.grunge2` | Grunge 2 |
| `image_editor.frame.ebony` | Ebony |
| `image_editor.frame.art1` | Art 1 |
| `image_editor.frame.art2` | Art 2 |
| `image_editor.filters.presets` | Presets |
| `image_editor.filters.adjust` | Adjust |
| `image_editor.filters.group.light` | Light |
| `image_editor.filters.group.color` | Color |
| `image_editor.filters.group.effects` | Effects |
| `image_editor.filters.brightness` | Brightness |
| `image_editor.filters.contrast` | Contrast |
| `image_editor.filters.saturation` | Saturation |
| `image_editor.filters.vibrance` | Vibrance |
| `image_editor.filters.hue` | Hue |
| `image_editor.filters.gamma` | Gamma |
| `image_editor.filters.blur` | Blur |
| `image_editor.filters.pixelate` | Pixelate |
| `image_editor.filters.noise` | Noise |
| `image_editor.filters.sharpen` | Sharpen |
| `image_editor.filters.grayscale` | Grayscale |
| `image_editor.filters.none` | None |
| `image_editor.filters.black_white` | Black & White |
| `image_editor.filters.sepia` | Sepia |
| `image_editor.filters.vintage` | Vintage |
| `image_editor.filters.polaroid` | Polaroid |
| `image_editor.filters.kodachrome` | Kodachrome |
| `image_editor.filters.technicolor` | Technicolor |
| `image_editor.filters.brownie` | Brownie |
| `image_editor.filters.invert` | Invert |
| `image_editor.filters.emboss` | Emboss |
| `image_editor.labels.text` | Text |
| `image_editor.labels.shape` | Shape |
| `image_editor.labels.sticker` | Sticker |
| `image_editor.labels.drawing` | Drawing |
| `image_editor.labels.image` | Image |
#### AI Assistant
| Key | Default |
| :-------------------------------------------------------- | :---------------------------------------------------------------------------- |
| `image_editor.ai_assistant.nav_label` | AI |
| `image_editor.ai_assistant.title` | AI Assistant |
| `image_editor.ai_assistant.empty_state` | Choose or describe an edit |
| `image_editor.ai_assistant.placeholder` | Describe what you want to change… |
| `image_editor.ai_assistant.editing` | Editing… |
| `image_editor.ai_assistant.progress.analyzing` | Analyzing image… |
| `image_editor.ai_assistant.progress.applying` | Applying changes… |
| `image_editor.ai_assistant.progress.finalizing` | Finalizing… |
| `image_editor.ai_assistant.done` | The changes have been applied. |
| `image_editor.ai_assistant.aborted` | Generation cancelled. |
| `image_editor.ai_assistant.error` | Our AI service is temporarily unavailable. Please try again in a few minutes. |
| `image_editor.ai_assistant.suggestions.remove_background` | Remove the background |
| `image_editor.ai_assistant.suggestions.vintage` | Make it look vintage |
| `image_editor.ai_assistant.suggestions.brightness` | Increase brightness |
| `image_editor.ai_assistant.suggestions.black_white` | Convert to black & white |
| `image_editor.ai_assistant.suggestions.blur_background` | Blur the background |
| `image_editor.ai_assistant.suggestions.warm_tone` | Add a warm tone |
| `image_editor.ai_assistant.suggestions.sharpen` | Sharpen the image |
| `image_editor.ai_assistant.suggestions.remove_text` | Remove text from image |
---
## Images
Images are an important part of designing an email or a landing page. Check the following resources to see what options are available to show user uploaded images or a custom media library.
---
## Stock Images
We have a built-in stock image library with millions of royalty-free images from Unsplash, Pexels and Pixabay. It's enabled by default but you can disable it if you want. You can also choose to change the safe searching option and the default search term.
Users open the **Images** tab in the tools panel, search for a term, and drag or click any result to insert it into the design.
```javascript
unlayer.init({
features: {
stockImages: {
enabled: true,
safeSearch: true,
defaultSearchTerm: 'people',
},
},
});
```
---
## SVG Images
If you would like to allow SVG image uploads, you can use the following feature flag. By default, SVG image uploads are disabled.
```javascript
unlayer.init({
features: {
svgImageUpload: true,
},
});
```
---
## Authentication
Every Unlayer server-side surface — the [Cloud API](/server/cloud-api), the [TypeScript SDK](/sdk), and the [CLI](/cli) — authenticates with a **bearer token** in the `Authorization` header:
```
Authorization: Bearer
```
Three token types are accepted. Token type is auto-detected from the prefix.
| Prefix | Token type | When to use |
| --------------- | --------------------- | ---------------------------------------------------------- |
| `unlayer_sk_*` | API Key | Server-side scripts, CI jobs, single-project integrations. |
| `unlayer_pat_*` | Personal Access Token | Personal use across multiple projects or workspaces. |
Both API Keys and Personal Access Tokens are managed from [Console](https://console.unlayer.com) — from a project's **Settings** sidebar, or all in one place under **Profile → API Tokens**. The direct URLs below open the same pages.
## Getting an API Key (`unlayer_sk_*`)
Best for server-side scripts, CI jobs, and single-project integrations. Scoped to **one project** — pick the project whose templates and data the token should be able to read/write.
1. Sign in to [Console](https://console.unlayer.com).
2. Pick the project you want the integration to act on and note its **Project ID** (it's in the URL once you open the project: `console.unlayer.com///...`).
3. Open the project's **Settings → API Keys** (the **v3** entry — not the v2 one, which is a separate legacy key), or navigate directly to:
```
https://console.unlayer.com/builder//settings/project-api-keys
```
Replace `` with your project's numeric ID.
4. Under **Create New API Key**, enter a descriptive name (e.g. _"Production"_, _"CI/CD"_, _"Claude Desktop"_) and click **Create**.
5. Copy the value that starts with `unlayer_sk_` — it's only shown once.
:::warning Show-once secret
API keys are displayed **only once at creation time**. Store the value in a secret manager or `.env` file immediately — Console only displays a partial fingerprint after that. If you lose the value, revoke the key and create a new one.
:::
### Using it
```bash
# Cloud API (Bearer token, v3 endpoints)
curl -H "Authorization: Bearer unlayer_sk_..." \
https://api.unlayer.com/v3/templates
# TypeScript SDK
const client = new Unlayer({ apiKey: process.env.UNLAYER_API_KEY });
```
## Getting a Personal Access Token (`unlayer_pat_*`)
Best for personal use when you want one token that works across multiple projects under your account. A PAT inherits your user's access — anywhere you can sign in to, the PAT can act.
1. Sign in to [Console](https://console.unlayer.com).
2. Note any **Project ID** from one of your projects (the route is scoped to a project ID for URL consistency, but the token itself isn't tied to that project).
3. Open **Settings → Personal Access Tokens** from any project (or **Profile → API Tokens**), or navigate directly to:
```
https://console.unlayer.com/home//profile/tokens
```
Replace `` with any project ID you have access to.
4. Click **Create New Token** and fill out:
- **Token Name** (e.g. _"CLI Token"_, _"My laptop — Claude Desktop"_).
- **Scope**: **All Workspaces** (cross-workspace access) or **Single Workspace** (pick one).
- **Expiration**: **Never** or a custom date (up to one year out).
5. Copy the value that starts with `unlayer_pat_` — it's only shown once.
PATs follow the same show-once rule as API keys. Revoke them from the same page if a laptop or client is lost.
### Project ID with PATs
Because a PAT isn't bound to a single project, server endpoints that need to know which project to act on require an explicit `projectId` query parameter or `X-Project-Id` header. API keys don't need this — the project is bound to the key on the server.
## Security checklist
- **Server-side only.** Never ship API keys or PATs to the browser — they grant full access to your project's data. Always proxy through your backend.
- **Use a secret manager.** Don't commit tokens to source control. Use Vercel/Netlify/AWS secret stores, GitHub Actions secrets, etc.
- **Rotate on compromise.** If a laptop is lost, a CI job is exposed, or a key leaks into a log — revoke it in Console immediately and issue a new one.
- **Least-privilege scope.** Prefer an API key (project-scoped) over a PAT (account-scoped) when the integration only needs one project.
## Related
- **[Cloud API](/server/cloud-api)** — REST endpoints these tokens authenticate against.
- **[Cloud API reference](/server/cloud-api/reference)** — full endpoint reference with auth headers shown per operation.
- **[CLI](/cli)** — terminal tool for managing templates and projects.
- **[TypeScript SDK](/sdk)** — programmatic Cloud API client (reads `UNLAYER_API_KEY` from the environment by default).
---
## Cloud API
The Cloud API is the **server-side** interface to Unlayer. It runs outside the browser and lets your backend:
- **Generate artifacts** (HTML, PDF, Image, ZIP) from a design JSON
- **Manage templates** stored in your Unlayer project
- **Convert** designs between [Full and Simple schema forms](/design-schema/)
- **Validate** a design against the Unlayer schema
- **Execute secure operations** that should not run in the browser
**[View the full API reference →](/server/cloud-api/reference)**
---
## When to use the Cloud API
Reach for the Cloud API when:
- You need to **generate a PDF** from a design in a background job
- You want to **export HTML** without exposing API keys to the browser
- You want to **generate an image preview** server-side
- You are running a **cron job**, webhook handler, or worker process
- You need to **validate or convert** a design payload before storing or rendering it
:::tip Embedding the builder?
If your use case involves embedding a visual builder or editor in your frontend, see [Builders](/builder/installation) instead.
:::
---
## How it works
Most server workflows follow this pattern:

1. A **Builder** produces a design JSON (in the browser).
2. Your **backend** sends that JSON to the [Cloud API](/server/cloud-api/reference).
3. The API returns a **generated artifact** (HTML, PDF, Image, ZIP) — or a validated / converted design payload.
4. You **store**, **send**, or **distribute** the result.
:::info Exporting HTML
For **HTML export**, you often don't need the Cloud API — the builder can produce HTML directly in the browser via [`unlayer.exportHtml()`](/builder/export-html). Use the Cloud API when you need to export HTML server-side (for example, in a background job or when the user is not in the builder).
:::
---
## Security model
The Cloud API uses **bearer-token authentication** — API Key, Personal Access Token, or OAuth. See **[Authentication](/server/authentication)** for token types, how to get each one from Console, and security guidelines.
:::warning Keep tokens server-side
Tokens grant full access to your project's data:
- **Never** call the Cloud API directly from the browser
- **Always** proxy requests through your backend
- **Keep** tokens in secure server-side environments only
:::
---
## Typical use cases
| Use case | Description |
| ----------------------------------- | ----------------------------------------------------------------------------- |
| **Export PDF in background job** | Generate a PDF after a user submits a form, then email it automatically. |
| **Generate HTML for email sending** | Export HTML server-side and pass it to your email provider. |
| **Image thumbnail generation** | Generate preview images for templates or documents. |
| **ZIP export for asset bundles** | Produce downloadable packages including images and required assets. |
| **Validate user-supplied designs** | Check that an imported or uploaded design conforms to the schema. |
| **Convert between schema forms** | Round-trip designs between the editor's Full shape and the wire Simple shape. |
---
## What's next
- See the [**API reference**](/server/cloud-api/reference) for the full endpoint list.
- See [**Design Schema**](/design-schema/) for the shape of the design JSON the API accepts and returns.
- If you're embedding the builder, go to [Builders](/builder/installation).
---
## Context (system prompt)
:::caution Planned, not yet shipped
The `features.ai.context` customization is on the roadmap. The shape and exact API may change before release. This page documents the planned design so embedders can plan integrations and so the docs are ready when the feature lands.
:::
## What it will do
Embedders will be able to seed every AI Assistant turn with their own **prior messages** — brand voice rules, accessibility checklists, layout conventions, per-customer prompts, multi-turn context — through a new `features.ai.context` block on `unlayer.init`:
```javascript
// PLANNED API (subject to change before release)
unlayer.init({
features: {
ai: {
enabled: true,
assistant: true,
context: {
messages: [
{
role: 'system',
content:
'You are designing for Acme Corp. Always use plain English. Never mention competitors.',
},
{
role: 'user',
content: 'Our brand voice is friendly and concise.',
},
{ role: 'assistant', content: 'Got it — friendly and concise.' },
],
metadata: {
customerId: 'acme-42',
brandTier: 'enterprise',
},
},
},
},
});
```
The input shape will mirror what the `/v3/templates/generate` API endpoint already accepts on its `messages` field, so the same payload works on both server and client. A system prompt is just a message with `role: 'system'` — no separate top-level field needed.
## Why a separate page
This is a **single point of leverage** for getting consistent, on-brand output across every AI turn in your embed — without prompting end users to repeat the same instructions. It's significant enough to live on its own page rather than as one bullet inside the model-picking docs.
## Status
Not yet implemented. Track progress and discussions in the AI Assistant roadmap, or contact your account manager if you have a specific use case you'd like to weigh in on.
## Related
- **[Picking a Model](./picking-a-model.md)** — pin which model powers the assistant.
- **[AI Assistant overview](../overview.md)** — what the assistant does and where users find it.
---
## Custom Claude Key
You can configure your own Anthropic (Claude) API key to power AI features in your project. This routes Anthropic-model requests through your own Anthropic account so:
- **Billing** — the underlying token cost is charged to your Anthropic account directly, not Unlayer's. Unlayer's [percentage on top](/ai/assistant/pricing) still applies — it covers routing, orchestration, and observability on top of the model call.
- **Rate limits** — your Anthropic account's rate limits apply, not Unlayer's shared pool.
## Setting up your custom key
1. Open your project in [Console](https://console.unlayer.com).
2. Navigate to **Premium Features**.
3. Paste your Anthropic API key into the **Anthropic API Key** input.
4. Click **Save Key**.
The key is validated against Anthropic before being stored. Once saved, every AI feature that resolves to an Anthropic model uses this key instead of Unlayer's default.
## What this affects
Your custom key powers Anthropic-routed AI requests. In particular, Claude is **Unlayer's default preferred model for template generation in the [AI Assistant](/ai/assistant)** — the heaviest design-quality work (full template generation, multi-row restyles, anything where visual taste matters) routes to Claude by default. With a custom key, those calls bill to your Anthropic account.
OpenAI requests are unaffected — see [Custom OpenAI Key](./custom-openai-key.md) — and image generation runs on Gemini — see [Custom Gemini Key](./custom-gemini-key.md).
## If your key stops working
If your Anthropic key is revoked, deleted, or otherwise rejected by the provider, AI requests that route through it fail with a clear in-editor message: _"The custom AI provider API key configured for this project was rejected. Update or remove it in the Unlayer Console to restore AI features."_ Update the key in Console — or remove it to fall back to Unlayer's built-in key (standard billing applies).
## Related
- **[Custom OpenAI Key](./custom-openai-key.md)** — same flow for OpenAI billing.
- **[Custom Gemini Key](./custom-gemini-key.md)** — same flow for Google Gemini billing.
- **[Pricing](/ai/assistant/pricing)** — how Unlayer prices AI usage.
---
## Custom Gemini Key
You can configure your own Google Gemini API key to power AI features in your project. This routes Gemini-model requests through your own Google account so:
- **Billing** — the underlying token cost is charged to your Google account directly, not Unlayer's. Unlayer's [percentage on top](/ai/assistant/pricing) still applies — it covers routing, orchestration, and observability on top of the model call.
- **Rate limits** — your Google account's rate limits apply, not Unlayer's shared pool.
## Setting up your custom key
1. Open your project in [Console](https://console.unlayer.com).
2. Navigate to **Premium Features**.
3. Paste your Gemini API key into the **Gemini API Key** input.
4. Click **Save Key**.
The key is validated against Google before being stored. Once saved, every AI feature that resolves to a Gemini model uses this key instead of Unlayer's default.
## What this affects
Your custom key powers Gemini-routed AI requests. In particular, Unlayer uses **Gemini** as the image-generation provider — generating new images and editing existing ones from a prompt both call Gemini. With a custom key, those image-generation calls bill to your Google account.
OpenAI-model requests continue to route through Unlayer's hosted service unless you've configured a [Custom OpenAI Key](./custom-openai-key.md).
## If your key stops working
If your Google key is revoked, deleted, or otherwise rejected by the provider, AI requests that route through it fail with a clear in-editor message: _"The custom AI provider API key configured for this project was rejected. Update or remove it in the Unlayer Console to restore AI features."_ Update the key in Console — or remove it to fall back to Unlayer's built-in key (standard billing applies).
## Related
- **[Custom OpenAI Key](./custom-openai-key.md)** — same flow for OpenAI billing.
- **[Custom Claude Key](./custom-claude-key.md)** — Anthropic equivalent.
- **[Pricing](/ai/assistant/pricing)** — how Unlayer prices AI usage.
---
## Custom OpenAI Key
You can configure your own OpenAI API key to power AI features in your project. This routes OpenAI-model requests through your own OpenAI account so:
- **Billing** — the underlying token cost is charged to your OpenAI account directly, not Unlayer's. Unlayer's [percentage on top](/ai/assistant/pricing) still applies — it covers routing, orchestration, and observability on top of the model call.
- **Rate limits** — your OpenAI account's rate limits apply, not Unlayer's shared pool.
## Setting up your custom key
1. Open your project in [Console](https://console.unlayer.com).
2. Navigate to **Premium Features**.
3. Paste your OpenAI API key into the **OpenAI API Key** input.
4. Click **Save Key**.
The key is validated against OpenAI before being stored. Once saved, every AI feature that resolves to an OpenAI model uses this key instead of Unlayer's default.
## What this affects
Your custom key powers OpenAI-routed AI requests. The AI Assistant uses your key whenever an OpenAI model is selected for the turn. Image generation does **not** route through OpenAI — it uses Google's Gemini, so configure a [Custom Gemini Key](./custom-gemini-key.md) if you want image generation billed to your own provider account too.
## If your key stops working
If your OpenAI key is revoked, deleted, or otherwise rejected by the provider, AI requests that route through it fail with a clear in-editor message: _"The custom AI provider API key configured for this project was rejected. Update or remove it in the Unlayer Console to restore AI features."_ Update the key in Console — or remove it to fall back to Unlayer's built-in key (standard billing applies).
## Related
- **[Custom Gemini Key](./custom-gemini-key.md)** — same flow for Google Gemini billing.
- **[Custom Claude Key](./custom-claude-key.md)** — same flow for Anthropic billing.
- **[Pricing](/ai/assistant/pricing)** — how Unlayer prices AI usage.
---
## Picking a Model
`features.ai.model` controls the preferred model for the AI Assistant's text
and chat — generating and editing copy, layouts, and design. It is
independent of [`features.ai.image.model`](./picking-an-image-model.md),
which only affects AI image generation and editing.
:::tip Recommended: let Unlayer pick
Leave `features.ai.model` unset and Unlayer picks the best model per
request. Set it only when you need to pin a specific provider or model.
:::
## Pin a model
`features.ai.model` accepts:
- a `"/"` pair (e.g. `"openai/gpt-5.5"`, `"anthropic/claude-opus-4-7"`) — pins both provider and model, unambiguous;
- a bare model id (e.g. `"gpt-5.5"`);
- a provider name (e.g. `"openai"`, `"anthropic"`) — starts on that vendor and lets Unlayer pick that provider's default model.
Pinned models are treated as the first choice for the request. If the
selected model or provider is rate-limited, overloaded, unavailable, or
returns another transient provider error before the user sees output,
Unlayer may retry the turn against a backup model so the assistant can
stay available during provider incidents.
```javascript
unlayer.init({
features: {
ai: {
enabled: true,
assistant: true,
// Provider only — Unlayer picks that provider's default model:
model: 'anthropic',
// Or pin an exact model:
// model: 'openai/gpt-5.5',
},
},
});
```
Supported providers: `openai`, `anthropic`.
## Configure fallback models
`features.ai.fallbackModels` controls the transient-outage fallback tail:
- omit it to use Unlayer's default fallback picks only when `features.ai.model`
is unset;
- set `true` to use Unlayer's default fallback picks even when
`features.ai.model` pins a provider or model;
- set `false` to disable the default outage fallback tail;
- pass an ordered model array to replace the default fallback picks.
Fallbacks are only used for provider availability failures such as
network errors, overloads, rate limits, and 5xx responses. They are not
used for invalid request bodies, invalid API keys, authorization errors,
credit exhaustion, or errors that require a change from you or Unlayer.
```javascript
unlayer.init({
features: {
ai: {
enabled: true,
assistant: true,
model: 'anthropic/claude-opus-4-7',
// Try these only if the preferred model has a transient outage:
fallbackModels: ['anthropic/claude-opus-4-6', 'openai/gpt-5.5'],
// Or use Unlayer's default outage tail after this pinned model:
// fallbackModels: true,
// Or disable default outage fallbacks:
// fallbackModels: false,
},
},
});
```
## Quality vs. cost
If you do pin a model, here's how the tiers generally compare:
| Tier | Examples | Relative cost | Best for |
| ---------------- | ----------------------------------- | ------------- | ------------------------------------------------------------------------------------------------- |
| **Frontier** | `claude-opus-4-7`, `gpt-5.5` | Higher | Design-heavy work — generating or restyling templates. Strong visual taste and copy quality. |
| **Mid-tier** | `claude-sonnet-4-6`, `gpt-5.5-mini` | Standard | Solid all-rounder. Reasonable design awareness at mainstream rates. |
| **Small / fast** | `claude-haiku-4-5`, `gpt-5.5-nano` | Lower | Short text rewrites only. Avoid for design generation — tends to produce flat, low-taste layouts. |
## Related
- **[Setup](../setup.md)** — how the assistant is enabled per project.
- **[Picking an image model](./picking-an-image-model.md)** — choose the model for AI image generation and editing.
- **[Custom OpenAI Key](./custom-openai-key.md)** — bring your own OpenAI billing.
- **[Custom Claude Key](./custom-claude-key.md)** — bring your own Anthropic billing.
- **[Pricing](../pricing.md)** — how AI usage is billed.
---
## Picking an Image Model
`features.ai.image.model` controls the preferred model for AI **image**
generation and editing — **Magic Image** and the **image editor's AI
chat**. It is independent of [`features.ai.model`](./picking-a-model.md),
which only affects the assistant's text/chat model.
:::tip Recommended: let Unlayer pick
Leave `features.ai.image` unset and Unlayer picks the best image model for
you. Set it only when you need to pin a specific provider or model.
:::
## Pin a model
`features.ai.image.model` mirrors [`features.ai.model`](./picking-a-model.md). It accepts:
- a `"/"` pair (e.g. `"openai/gpt-image-2"`, `"google/gemini-3.1-flash-image-preview"`) — pins both provider and model, unambiguous;
- a bare model id (e.g. `"gpt-image-2"`);
- a provider name (e.g. `"openai"`, `"google"`) — starts on that vendor and lets Unlayer pick that provider's default image model.
The selected model is the first choice. If that provider is overloaded,
rate-limited, unavailable, or returns another transient provider error,
Unlayer may retry the request against a fallback image model when you set
`fallbackModels` to `true` or an explicit ordered list.
```javascript
unlayer.init({
features: {
ai: {
enabled: true,
assistant: true,
image: {
// Provider only — Unlayer picks that provider's image model:
model: 'openai',
// Or pin an exact model:
// model: 'openai/gpt-image-2',
// model: 'google/gemini-3.1-flash-image-preview',
// Quality tier (low | medium | high). Defaults to 'medium'.
// quality: 'high',
// Optional transient-outage fallback controls:
// fallbackModels: ['google/gemini-2.5-flash-image', 'openai/gpt-image-1.5'],
// fallbackModels: true,
// fallbackModels: false,
},
},
},
});
```
Supported providers: `google` and `openai`. An unsupported provider or image
model is rejected.
## Configure fallback image models
`features.ai.image.fallbackModels` controls the transient-outage fallback
tail for image generation and editing:
- omit it to use Unlayer's default fallback picks only when
`features.ai.image.model` is unset;
- set `true` to use Unlayer's default fallback picks even when
`features.ai.image.model` pins a provider or model;
- set `false` to disable the default outage fallback tail;
- pass an ordered model array to replace the default fallback picks.
Fallbacks are only used for provider availability failures such as
network errors, overloads, rate limits, and 5xx responses. They are not
used for invalid image prompts, unsupported model IDs, invalid API keys,
authorization errors, credit exhaustion, or errors that require a change
from you or Unlayer.
## Quality vs. cost
If you do pin a model, here's how the provider families generally compare:
| Provider family | Relative cost | Best for |
| --------------- | ------------- | ----------------------------------------------- |
| OpenAI image | Varies | Explicit opt-in control over OpenAI image SKUs. |
| Gemini image | Standard | Strong all-rounder for generation and editing. |
The `quality` tier (`low` | `medium` | `high`) applies to every provider — higher tiers cost more. It's mapped internally (OpenAI → quality tier, Gemini → 1K/2K/4K resolution).
## Related
- **[Picking a model](./picking-a-model.md)** — the assistant's text/chat model.
- **[Custom OpenAI Key](./custom-openai-key.md)** — bring your own OpenAI billing.
- **[Custom Gemini Key](./custom-gemini-key.md)** — bring your own Google billing.
- **[Pricing](../pricing.md)** — how AI usage is billed.
---
## Events & Callbacks(Assistant)
The AI Assistant emits a small set of lifecycle callbacks you can subscribe to from the host app — register them after the editor is initialized using `unlayer.addEventListener` (alias of `registerCallback`). Use them to log generations, build per-project dashboards, stream the assistant's output into your own UI, or feed your own analytics pipeline.
## Common use case: logging prompts & per-user analytics
The most common reason to wire these callbacks is to **track what your users ask the assistant** — for product analytics, per-user accounting, support correlation, or content moderation. The recommended pattern:
1. Subscribe to **`ai:assistant:on:request`** to capture the user's prompt and the target scope (which row / block / text selection it was scoped to).
2. Subscribe to **`ai:assistant:on:success`** (and `:on:error` / `:on:cancel`) to record the outcome and duration.
3. Forward each event to your analytics pipeline (Mixpanel, Amplitude, Segment, your own warehouse) keyed by the user id you already know in the host app.
Every lifecycle event already carries the embed's attribution context (see [Shared context fields](#shared-context-fields)), so you can key analytics directly off the event payload — no need to close over your own user object. The end-user id on the events is the one you pass via [End-User Identification](/builder/end-user-identification), which is also what scopes the editor's own per-user state (saved blocks, uploads).
```javascript
unlayer.addEventListener('editor:ready', () => {
unlayer.addEventListener('ai:assistant:on:request', (params) => {
analytics.track('ai_prompt_sent', {
userId: params.endUserId, // end-user from unlayer.init({ user })
projectId: params.projectId,
workspaceId: params.workspaceId,
requestId: params.requestId,
prompt: params.prompt,
scope: params.dataType, // 'template_block' | 'row' | 'content_text' | ...
target: params.location, // { collection, id }
});
});
unlayer.addEventListener('ai:assistant:on:success', (data) => {
analytics.track('ai_prompt_completed', {
userId: data.endUserId,
responseId: data.responseId,
durationMs: data.durationMs,
locale: data.locale,
toolType: data.toolType,
});
});
});
```
The event reference below documents the full payload of each lifecycle event.
## Shared context fields
Every lifecycle event (`on:request`, `on:success`, `on:error`, `on:cancel`) carries these attribution fields in addition to its own payload:
- **`projectId`** — the numeric project id from `unlayer.init({ projectId })`; `null` when none was passed.
- **`workspaceId`** — id of the workspace the project belongs to; may be `null` on older API deployments.
- **`endUserId`** — id of the end-user identified via [`unlayer.init({ user })`](/builder/end-user-identification); `null` when no end-user is identified.
- **`featureType`** — classification of the AI feature used this turn: `'full_template_gen' | 'block_edit' | 'html_import' | 'image_import' | 'image_generation'`; `null` when the turn has no classification.
## `ai:assistant:on:request`
Fires at the start of every turn, before any AI call. Use it to time turns, count requests, or capture the prompt and target location.
```javascript
unlayer.addEventListener('ai:assistant:on:request', function (params) {
console.info('AI turn started', {
requestId: params.requestId,
prompt: params.prompt,
dataType: params.dataType, // e.g. 'template_block', 'row', 'content_text'
location: params.location, // { collection, id }
});
});
```
## `ai:assistant:on:success`
Fires when a turn completes successfully. Carries the wall-clock duration, the detected user locale, and the design payload the assistant produced.
```javascript
unlayer.addEventListener('ai:assistant:on:success', function (data) {
console.info('AI turn succeeded', {
responseId: data.responseId,
durationMs: data.durationMs,
locale: data.locale, // detected from the user's last message
toolType: data.toolType,
});
});
```
## `ai:assistant:on:error`
Fires when a turn fails — after the editor has already retried internally. Includes the error name and HTTP status code if applicable. Cancellations route to [`ai:assistant:on:cancel`](#aiassistantoncancel) instead — they do not fire here.
```javascript
unlayer.addEventListener('ai:assistant:on:error', function (data) {
console.warn('AI turn failed', {
name: data.name,
message: data.message,
statusCode: data.statusCode,
durationMs: data.durationMs,
});
});
```
## `ai:assistant:on:cancel`
Fires when a turn is interrupted before completion — the user clicked Stop, started a new request, removed the target element, or the request timed out. The `reason` discriminates the cause.
```javascript
unlayer.addEventListener('ai:assistant:on:cancel', function (data) {
console.info('AI turn cancelled', {
reason: data.reason, // 'stop_button' | 'new_request' | 'item_removed' | 'timeout'
});
});
```
## `ai:assistant:on:stream`
:::caution Beta
`ai:assistant:on:stream` is in beta. The set of `event.type` values, the shape of each payload, and the firing semantics may change as the streaming pipeline evolves.
:::
Fires for every internal stream event the assistant produces during a turn — status text, partial text deltas, follow-up suggestions, and design operations as they arrive. Useful for mirroring the assistant's output into your own UI, building progress indicators, or debugging.
The payload is a discriminated union — switch on `event.type` to handle each kind:
```javascript
unlayer.addEventListener('ai:assistant:on:stream', function (event) {
switch (event.type) {
case 'start':
// A new assistant turn began.
console.info('AI stream started', {
toolType: event.toolType,
messageId: event.messageId,
});
break;
case 'status':
// Human-readable progress label (e.g. "Analyzing your request…").
console.info('AI status:', event.text);
break;
case 'text-delta':
// Incremental text chunk for the chat bubble identified by blockId.
appendToBubble(event.blockId, event.delta);
break;
case 'suggest-options':
// Quick-reply suggestions the assistant proposes for the user's next turn.
renderQuickReplies(event.options); // [{ label, prompt }, ...]
break;
case 'design-partial':
// Snapshot of the design after a structural update.
console.info('Design snapshot', event.full);
break;
case 'design-operation':
// A single design mutation (add/remove/move/update) about to be applied.
console.info('Design op', event.op);
break;
}
});
```
---
## Examples
Screenshots and walkthroughs of the AI Assistant.
## Generating a full email from a prompt
## Restyling an existing template
## Editing a single block
## Picking from multiple suggestions
## Generating an image
---
## FAQ & Troubleshooting
Missing something? Email [support@unlayer.com](mailto:support@unlayer.com) or use the in-product chat in [Console](https://console.unlayer.com).
## Pricing & credits
### How much does it cost?
The AI Assistant runs on a **credit-based model** — every Unlayer plan that includes AI ships with a per-period credit cap (applied at the workspace level, shared by every project in the workspace), and every AI request consumes credits proportional to the work it does (a small text rewrite is a fraction of a full-template generation, for example). For current plan tiers and credit-pack pricing, see [unlayer.com/pricing](https://unlayer.com/pricing); for how credits are spent, see [Pricing](./pricing.md). Workspaces on a paid plan also get **one-time evaluation credits** so you can try the AI Assistant in dev or staging before topping up.
### Can I get a surprise bill if my users go heavy on AI?
**No.** Your AI cost is capped at your **workspace's** plan credit allowance every period. When that cap is reached, the AI Assistant disables itself across every project in the workspace — nothing keeps charging in the background, and nothing auto-bills. Adding more credits is always opt-in (a credit-pack purchase or a plan upgrade you choose). See [Pricing → When you run out of credits](./pricing.md#when-you-run-out-of-credits).
### How do I track what my users ask the assistant?
Subscribe to the AI lifecycle callbacks from your host app and forward the events to your analytics pipeline. **`ai:assistant:on:request`** carries the prompt and the target scope; **`ai:assistant:on:success`** / **`:on:error`** / **`:on:cancel`** carry the outcome and duration. Each event also carries the `projectId`, `workspaceId`, and the identified end-user (from [End-User Identification](/builder/end-user-identification)), so per-user analytics, support correlation, free-tier rate limits, and content moderation work directly off the event payload. For a worked AI-events example, see [Events & Callbacks → Common use case: logging prompts & per-user analytics](./events-callbacks.md#common-use-case-logging-prompts--per-user-analytics).
### Why does the chat say I'm out of credits in my dev project?
Credits are pooled at the **workspace** level — every project in a workspace shares the same per-period credit allowance, so a dev project and a production project under the same workspace draw from the same pool. Workspaces on a paid plan get a fixed amount of **one-time evaluation credits** the first time AI is enabled — useful for prototyping in dev or staging without spending plan credits right away. Once those are spent, you'll need to either upgrade the plan or buy a credit pack to keep generating. Free-plan workspaces don't get evaluation credits — AI is a paid feature. See [Pricing](./pricing.md).
### Is there a per-user rate limit?
There's no built-in per-user limit at the editor level. Credit caps apply at the **workspace** level — every project and every user under a workspace draws from the same allowance. If you need per-user accounting, capture `ai:assistant:on:success` events from the host app — they carry the identified end-user (from [End-User Identification](/builder/end-user-identification)) along with `projectId` and `workspaceId`, so you can attribute usage without joining against your own state.
### Why is full-template generation more expensive than a one-block edit?
Full-template turns reason over the entire layout and emit a much larger payload — that's more work for the model, which translates to more credits per turn. For day-to-day iteration on a paragraph or single block, prefer per-element edits — they cost a small fraction of a full-template turn. See [Full Template tradeoffs](./how-it-works/full-template.md#tradeoffs).
## Setup & visibility
### How do I enable or disable the AI Assistant?
The AI Assistant is **on by default** — you don't need a feature flag to turn it on. It becomes available in the editor's mode switcher when:
1. **`features.ai.assistant`** is left at its default of `true` (you only set it to `false` to disable).
2. The project belongs to a workspace whose **plan includes the AI Assistant**. Check the plan in [Console](https://console.unlayer.com) → Project → Settings.
3. The init **identifies the end-user** — a `user` object with an `id` is passed to `unlayer.init`. See [End-User Identification](/builder/end-user-identification).
To **disable** it on a specific embed (e.g. a free-tier user, an editor surface where you don't want AI), pass `features.ai.assistant: false` on that `unlayer.init` call. The assistant is hidden in that editor instance only.
If the AI Assistant is missing despite the plan covering AI, open the browser console — the editor prints a clear error when the plan / feature-flag combination doesn't pass. See [Setup](./setup.md) for the full reference and [Setup → Disabling per init](./setup.md#disabling-per-init) for the disable pattern.
### Can I run the assistant offline / on-premise?
No. The assistant requires a network call to Unlayer's AI service (and from there to the model providers). On-premise deployments are not currently supported.
## Privacy & data
### Does the AI Assistant train on my data?
No. Prompts and surrounding design context are sent to Unlayer's AI service and forwarded to the underlying model providers (OpenAI, Anthropic, and others Unlayer uses as subprocessors). Inputs are **not** used to train models. For data-handling specifics tied to your contract — data residency, retention, DPA — contact your account manager.
### Can my end users opt out of having their prompts sent to a third-party AI?
Yes — disable the assistant for that user by passing `features.ai.assistant: false` on the `unlayer.init` call that loads the editor for them. The editor doesn't ship a built-in "AI on/off" toggle for end users; that decision lives in your application's UI.
### Does the assistant store my prompts?
Unlayer logs prompts for short-term debugging and abuse prevention. The model providers may apply their own retention windows per their terms. For zero-retention agreements, contact your account manager.
## Behavior & quality
### The assistant skipped my custom block. Why?
Your custom tool likely uses **custom widgets or its own property editors**, which the assistant can't introspect on its own. Declare the tool's value shape by passing a `schema` field on the registration call. See [Custom Tools](./how-it-works/custom-tool.md#custom-widgets-and-property-editors).
### Why doesn't the assistant know the values of my merge tags?
Merge tags are **preserved literally** in the output — `{{ first_name }}` comes back as `{{ first_name }}`, not "John". The assistant doesn't see your data; it sees the tag and treats it as an opaque placeholder. If you want the assistant to reason about realistic data, pass a sample value through your application copy ("e.g. _Hi John_") instead of relying on the tag itself.
### Why does the output look generic / not match my brand voice?
The assistant has no built-in knowledge of your brand. Today the best ways to nudge tone are:
- Ask explicitly in the prompt (_"sound less salesy and more helpful"_, _"match the tone of the heading above"_).
- Give the assistant nearby context — having well-written body copy in the same section makes it easier for the assistant to match.
### What languages does the assistant support?
The assistant auto-detects the language from the user's last message and replies in that language. Generation works in any language the underlying model handles — Spanish, French, German, Portuguese, Italian, Japanese, Korean, Chinese, Arabic, and so on. The editor UI itself is translated separately — see [Localization](/builder/localization).
### Why is the assistant producing different results for the same prompt?
LLM outputs are stochastic; identical prompts will produce slightly different results each turn. That's expected. The **[Ask for multiple options](./how-it-works/text.md#ask-for-multiple-options)** flow takes advantage of this: ask for 5 alternatives and pick the best one.
## Performance
### Is it fast?
**Yes.** Per-block edits (text, button, heading, image alt text) typically finish in **a few seconds**, and the assistant uses **real-time streaming** to apply changes to the visual editor as the model generates them: status text appears, then text streams into chat bubbles, and design operations land on the canvas one at a time — users see the design update live instead of waiting on a spinner. Routing also helps: simpler prompts are sent to a smaller, faster model (cheaper and quicker), while frontier models are reserved for prompts detected as complex. The one exception is **full-template generation**, which reasons over the entire layout in a single turn and is meaningfully slower (tens of seconds) — that's a deliberate tradeoff for the depth of work it does. See [Full Template tradeoffs](./how-it-works/full-template.md#tradeoffs).
### Why is full-template generation taking 30+ seconds?
Full-template turns are the heaviest work the assistant does — they reason over the entire layout and emit a much larger payload. **Tens of seconds is the expected range** for full-template work; sometimes longer for very large designs. See [Full Template tradeoffs](./how-it-works/full-template.md#tradeoffs).
For faster iteration on a single block or paragraph, prefer per-element edits — those typically finish in a few seconds.
## Debugging
### Reporting an assistant failure to Unlayer support
The faster you give us context, the faster we can reproduce. When opening a ticket, include:
- **Your project ID** — find it in [Console](https://console.unlayer.com) → Project → Settings.
- **Editor version** — run `unlayer.version` in the browser console (with the editor loaded on the page) and paste the value.
- **The prompt** the user typed — exact text, ideally copied from the chat input.
- **When it happened** — approximate timestamp in your local time zone or UTC. A 5-minute window is enough for us to find the request in our logs.
- **What you expected vs. what actually happened** — _"I asked the assistant to translate this row to Spanish; it rewrote the row in English instead"_ is much more useful than _"AI broke"._
- **A screenshot or short screen recording** of the failure — especially anything visible in the chat (status text, partial output, error banner).
- **Browser console output**, if any errors logged — especially anything prefixed with `[unlayer]`.
## Related
- **[Setup](./setup.md)** — gating, plan requirements, and the disable-per-init pattern.
- **[Pricing](./pricing.md)** — credit-based model and what to do when you run out.
- **[Events & Callbacks](./events-callbacks.md)** — programmatic hooks for logging and analytics.
- **[How it works](./how-it-works/full-template.md)** — what the assistant does at each scope.
---
## Custom Tools(How-it-works)
Embedders can register their own content blocks via `unlayer.registerTool(...)` — see [Custom Tools](/builder/tools/custom/create) for the registration API. The AI Assistant understands and edits these blocks too, with one configuration step for the cases it can't introspect on its own.
## Automatic support
If your custom tool only uses Unlayer's [**built-in property editors**](/builder/tools/custom/property-editors) (text, color, font, link, image, dropdown, checkbox, slider, etc.), it works with the AI Assistant automatically. Drop the tool into a design, ask the assistant to edit it, done.
## Custom widgets and property editors
When a property uses a **custom property editor** built with `unlayer.createWidget(...)`, the assistant can't infer the shape on its own — the widget renders arbitrary UI and stores arbitrary values that Unlayer doesn't know about. In that case, you declare the shape explicitly by passing a `schema` field on the **widget**, not on the tool. The schema lives next to the widget's `mount` and `render` functions:
```javascript
unlayer.registerPropertyEditor({
name: 'my_color_picker',
Widget: unlayer.createWidget({
schema: {
type: 'string',
description: 'Hex color value, e.g. "#FF5733".',
},
render(value) {
/* return HTML for the editor */
},
mount(node, value, updateValue) {
/* wire up the editor */
},
}),
});
```
The `schema` is a [JSON Schema](https://json-schema.org/) describing the **value** the widget produces. The assistant uses it to understand what the field accepts and to write valid values into it. See [Custom Tools → Custom Property Editor](/builder/tools/custom/create#custom-property-editor) for the full registration API and a complete example.
Without a `schema` on the widget, the assistant skips properties backed by that custom editor — it surfaces a message to the user explaining the property isn't AI-aware instead of trying to write into a structure it doesn't understand. With the `schema`, those properties become first-class targets for the assistant.
### Schema tips
- Use `description` on the schema (and on nested properties when the value is an object) — the assistant reads descriptions like prompt instructions, so a sentence meaningfully improves output quality.
- Constrain enums (`enum: [...]`), formats (`format: 'uri' | 'email' | 'date-time'`), and ranges (`minimum`, `maximum`) where you can. Tighter constraints produce fewer invalid edits.
- Mark fields `required` only when the value genuinely won't render without them. Optional fields give the assistant room to leave existing values untouched.
- Keep nested objects shallow. Deeply nested schemas work, but a flatter shape is easier for the assistant to reason about and produces more predictable edits.
## Related
- **[Custom Tools (registration API)](/builder/tools/custom/create)** — how to register a custom tool with Unlayer.
- **[Custom Property Editor](/builder/tools/custom/create#custom-property-editor)** — the full `createWidget` + `registerPropertyEditor` reference.
- **[Design Schema](/design-schema/content-types#custom-tools)** — how custom tools appear in the design JSON.
- **[AI Assistant overview](../overview.md)** — what the assistant does and where users find it.
---
## Full Template
The AI Assistant can generate or restyle an **entire template** in a single turn — not just a row, content block, or text selection. This is the heaviest design-quality work the assistant does, but also the **easiest and most powerful** path for a user to go from a blank canvas (or an existing template that needs a full overhaul) to a finished design.
## When it shines
- **Starting a new template from scratch** — _"build me a Black Friday email for a streetwear brand, dark mode, with a hero, three-column product grid, and a footer"_ → the assistant produces the whole layout in one shot.
- **Restyling an existing template** — _"keep the same content but switch this from a casual newsletter to a corporate enterprise tone"_ → multi-row restyle in a single turn.
- **Bulk content + layout edits** — anything that touches the body as a whole (translating an entire email, swapping the brand voice, regenerating around a new product).
## Tradeoffs
Full template generation does more work than a per-block edit, and that shows up as:
- **Slower.** A full template turn typically takes **tens of seconds** (sometimes longer for very large designs), versus a few seconds for a single content rewrite. The assistant has to reason over the whole layout, propose a structure, and emit a much larger payload.
- **More credits per turn.** A full-template turn consumes meaningfully more credits than a per-element edit (see [Pricing](../pricing.md)).
- **More moving parts.** The pipeline has more steps — schema validation, multi-step planning, larger context windows. That means more surface for transient provider hiccups; the automatic [fallback chain](../overview.md#reliability--uptime) handles most of these silently.
For users iterating on a single block or paragraph, **per-element edits are almost always the better answer** — they're faster, cheaper, and more predictable.
## Disabling full template generation
If the cost or latency profile doesn't fit your product — or you want to keep the assistant available for fine-grained edits while removing the heavyweight option — set `features.ai.fullTemplateGeneration` to `false` in `unlayer.init`:
```javascript
unlayer.init({
features: {
ai: {
enabled: true,
assistant: true,
fullTemplateGeneration: false,
},
},
});
```
When disabled:
- The template-level "generate" affordance disappears from the editor.
- Row, header, footer, content-block, and text-level edits remain fully available.
You can also vary it per surface — turn it on in a power-user editor but off on a customer-facing embed where cost predictability matters more — by passing different `features.ai.fullTemplateGeneration` values to each `unlayer.init` call.
:::note HTML & screenshot import
The HTML / screenshot import flow (a console-only feature today — see [AI Template Importer](/ai/template-importer)) always produces a full template. The init flag described above only affects the editor — it doesn't reach the import API. To disable imports specifically, contact your Unlayer account manager.
:::
### Why disable it?
The most common reason is **cost predictability**. Full template generations have the largest per-request credit cost on the platform, so a project that opens the assistant up to non-power users may want to cap usage to the cheaper, faster per-element flows. Other reasons embedders cite:
- **Latency budget** — keeping every AI turn under a few seconds for a snappier UX.
- **Output consistency** — per-element edits respect the existing design more tightly; full generations re-author large parts of the layout.
- **Plan tier gating** — exposing full template generation only to higher-tier customers, while keeping the per-element assistant on every paid plan.
## Default behavior
When the AI Assistant is on, full template generation is **enabled by default** — no extra flag needed. You only need to set `features.ai.fullTemplateGeneration` if you specifically want to turn it off while keeping the rest of the assistant available.
## Related
- **[Row](./row.md)** — narrower scope when full-template is overkill.
- **[Text](./text.md)** — single-block edits for text, button, and heading tools.
- **[Pricing](../pricing.md)** — credit cost per request type.
- **[Picking a Model](../customization/picking-a-model.md)** — pinning a frontier model amplifies both the upside and the cost of full template generation.
- **[Full vs Simple schemas](/design-schema/schema-forms)** — the assistant reads and writes the compact Simple form of the design JSON; the editor converts to and from the Full form around each turn (the schema-validation step in the pipeline above).
- **[Events & Callbacks](../events-callbacks.md)** — `ai:assistant:on:success` reports the per-turn duration so you can see how much wall-clock time a full template turn costs.
---
## Image(How-it-works)
Two AI-powered image features ship inside the editor:
- **[Image Generation](#image-generation)** — generate new images or edit existing ones from a text prompt.
- **[Image Alt Text](#image-alt-text)** — generate the `alt` attribute for an image with one click.
## Image Generation
Generate brand-new images — or edit existing ones — directly inside the editor, using a text prompt. Useful when stock libraries don't have what the user needs and a designer isn't on hand.
### What it does
- **Generate from a prompt** — user types _"a turtle swimming underwater, expressionist painting"_ and the assistant returns several candidate images.
- **Style picker** — pick a theme: photorealistic, fantasy, vaporwave, line art, or any custom style description.
- **Edit existing image** — start from an image already in the design and ask the assistant to modify it: _"add sunglasses to the person"_, _"apply a black and white effect"_, _"change the background to a beach"_.
- **Prompt scoring** — the modal scores the prompt before generating to nudge users toward more descriptive prompts.
### How users access it
There are three entry points:
- In the Image content block, the image picker exposes an **Image Generation** option alongside Upload and Stock. Clicking it opens the prompt modal.
- Selecting an image and clicking the Sparkles button on the layer control opens the AI Assistant scoped to that image — the same generation and editing capabilities are available there.
- **Double-clicking an image** opens the Image Editor. When the AI Assistant is available, its prompt panel opens automatically alongside the canvas (the side navigation's **AI** button toggles it) — users can write a prompt to generate a new variation or to edit the current image without leaving the editor.
## Image Alt Text
Generate the `alt` attribute for an image with one click — useful for accessibility (screen readers) and for email deliverability (clients show alt text when images fail to load or are blocked by default).
### How users access it
In an Image content block's properties panel, the **Alt Text** field exposes a one-tap "Generate with AI" affordance. It looks at the image (and the surrounding design context) and writes a concise, descriptive alt string.
Users can also ask the AI Assistant in chat — _"write alt text for this image"_ — when an image element is selected.
### Why alt text matters
- **Accessibility** — screen readers use alt text to describe images to users with visual impairments.
- **Deliverability** — email clients show alt text when images fail to load (or are blocked by default), which is common in inbox previews.
## Related
- **[File Manager](/builder/file-manager)** — where generated images are stored.
- **[AI Assistant overview](../overview.md)** — what the assistant does and where users find it.
---
## Row
When the user selects a **Row**, the AI Assistant operates on the whole container — every column, every content block inside it — in a single turn. Useful for changes that need to be consistent across multiple blocks at once: translation, restyling, swapping layouts, regenerating around a new product.
## How users access it
- Select the row on the canvas — the layer control surfaces a Sparkles button. Clicking it opens the AI Assistant scoped to that row.
- Or open the AI Assistant chat and ask while the row is selected (_"translate this entire row to Spanish"_, _"restyle this row to be dark mode"_).
## What it can do
- **Translate the whole row** — _"translate this row to French, keep the merge tags."_ Every content block — text, button labels, headings, alt text — comes back in the target language.
- **Restyle / re-tone** — _"make this row darker and more enterprise"_, _"give this row a friendlier voice."_ The assistant rewrites copy across the row to match.
- **Swap the layout** — _"turn this row into a 2-column hero with the image on the left."_ The assistant restructures cells and columns while preserving the underlying content.
- **Regenerate content** — _"keep the layout, but redo the copy as if this is for Black Friday."_ Layout stays, content blocks come back rewritten.
- **Targeted edits across multiple blocks** — _"replace every CTA in this row with a single one at the bottom"_, _"swap both images for product shots."_
## When to use it instead of per-block edits
Per-block edits (text, button, heading, image) are faster and cheaper for single-element tweaks. Reach for row-level edits when:
- You need **consistency** across blocks — same tone, same translation, same restyle applied to several content items at once.
- You need to **restructure** the row's layout, not just its copy.
- You're doing a **batch** of related edits and would otherwise click between blocks one at a time.
For changes that touch the whole design (cross-row restyle, regenerate entire email), use [Full Template](./full-template.md) instead.
## Related
- **[Text](./text.md)** — single-block edits for text, button, and heading tools.
- **[Image](./image.md)** — image generation, editing, and alt text.
- **[Full Template](./full-template.md)** — whole-design edits in one turn.
- **[AI Assistant overview](../overview.md)** — what the assistant does and where users find it.
---
## Text(How-it-works)
The AI Assistant treats **Text**, **Button**, and **Heading** as one family of text tools. When asked to write or rework any of them — through a freeform prompt, a layer-control Sparkles button, or a one-tap quick action — the behavior is the same:
- It reads the surrounding section (heading, body, image alt text, nearby copy) and writes content that fits the context.
- It preserves inline formatting and merge tags. Bold spans, links, and `{{ merge_tags }}` come back intact.
- It returns suggestions inline as clickable pills the user can pick from.
The only thing that changes per tool is the expected output: a paragraph for Text, a short label for Button, a punchy line for Heading. Everything else on this page applies to all three.
## How users access it
After dropping or selecting a Text, Button, or Heading content block, the user can either:
- Open the AI Assistant chat and ask for what they want (_"shorten this paragraph and make it friendlier"_, _"suggest a CTA for this section"_, _"write me a heading for this section"_).
- Select the block and click the Sparkles button on the layer control to ask scoped to that element.
- On Text specifically: open the inline quick-action menu and pick a one-tap action (Fix Spelling & Grammar, Summarize Text, Expand Text, Make It Friendly, Make It Formal).
## Common rewrites
The quick-action menu on Text covers the most common rewrites in one click. Buttons and headings have the same intents available via chat:
| Intent | What it does |
| ---------------------- | ------------------------------------------------------------------- |
| Fix Spelling & Grammar | Corrects typos, agreement, punctuation. Preserves voice and length. |
| Expand Text | Adds detail and examples to make a short passage longer. |
| Summarize Text | Compresses a long passage into a tighter summary. |
| Rephrase Text | Same meaning, different words — useful for breaking up repetition. |
| Make It Friendly | Casual, warm tone shift. |
| Make It Formal | Professional, businesslike tone shift. |
## Ask for multiple options
Ask the assistant for a few variations in a single turn — _"give me 5 friendlier alternatives for this paragraph"_, _"give me 5 CTA options for this section"_, _"give me 5 heading options for this section"_ — and it returns each suggestion as a clickable pill in the chat. Click a pill to apply that suggestion to the selected block; the others stay around so the user can compare and pick a different one without re-prompting.
## Example prompts
Freeform chat goes beyond the quick-action menu — anything you can describe, the assistant can try. A few that work well across the three tools:
- _"Shorten this and make it friendlier."_
- _"Match the tone of the heading above."_
- _"Rewrite for a non-technical reader."_
- _"Translate to Spanish, keep the merge tags intact."_
- _"Suggest a CTA for this section — under 4 words."_
- _"Give me 3 heading options that lead with a benefit."_
## Related
- **[Image](./image.md)** — image generation, editing, and alt text.
- **[Custom Tools](./custom-tool.md)** — how the assistant works with embedder-defined custom tools.
- **[Row](./row.md)** — multi-block edits in one turn.
- **[AI Assistant overview](../overview.md)** — what the assistant does and where users find it.
---
## AI Assistant
The AI Assistant is a chat-based assistant built into the builder. Users describe what they want — _"translate this row to Spanish"_, _"generate a Black Friday hero"_, _"shorten this paragraph and make it friendlier"_ — and the assistant streams the changes directly into the design.
The assistant operates at any level of the design tree — full template, row, text, image, and your own custom tools. It also detects the user's locale from their last message, so replies and generated copy come back in that language even if the rest of the editor UI is in English.
## Where users find it
There are three entry points:
- **AI Assistant panel** — click the Sparkles button in the editor's top toolbar; a dedicated chat panel slides in on the side, with a persistent thread the user can scroll through.
- **Layer control popover** — when a user selects an element, the layer control surfaces a Sparkles button. Clicking it opens the assistant scoped to that selection.
- **Content tools input** — a prompt input pinned below the content tools in the Content tab. Typing a request and pressing Enter opens the AI Assistant panel and starts a turn for the whole design — a quick way to begin without first opening the panel.
## How it works
1. The user types a prompt (and optionally selects an element to scope the request).
2. The editor sends the prompt and the relevant slice of the design to Unlayer's AI service.
3. The service streams back text and design operations.
4. The editor applies design operations as they arrive — the canvas updates live.
5. The user can stop generation at any time, undo the result, or follow up with another prompt.
## Reliability & uptime
Unlayer's AI service runs a multi-provider routing layer with an automatic fallback chain — if the primary model is rate-limited, overloaded, unavailable, or returns another transient provider error, the request transparently retries against a backup model. Users don't see the retry; the turn just continues. Errors that require user input or configuration changes, such as invalid API keys or invalid request bodies, fail immediately.
## Quick start
The AI Assistant is on by default — a standard `unlayer.init` is enough, as long as your plan includes it and the init [identifies the end-user](/builder/end-user-identification). To be explicit (or to disable it), use `features.ai`:
```javascript
unlayer.init({
features: {
ai: {
enabled: true,
assistant: true, // defaults to true; set false to disable
},
},
});
```
See [Setup](./setup.md) for the full reference.
## What's in this section
- **[How it works](./how-it-works/full-template.md)** — what the assistant does at each scope: full template, row/header/footer, text, image, and custom tools.
- **[Setup](./setup.md)** — enable the assistant in your embed.
- **[Pricing](./pricing.md)** — how AI usage is billed.
- **[Examples](./examples.md)** — live demos and starter snippets.
- **[Events & Callbacks](./events-callbacks.md)** — track every AI turn and forward it to your analytics pipeline.
- **[FAQ & Troubleshooting](./faq.md)** — answers to the most common questions and failure modes.
## Privacy & data
User prompts and the surrounding slice of the design are sent to Unlayer's AI service, which routes the request to one of Unlayer's model-provider subprocessors (currently OpenAI and Anthropic). Inputs are **not used to train models**. Unlayer logs prompts for short-term debugging and abuse prevention; model providers may apply their own retention windows per their published terms.
For data-handling specifics tied to your contract — data residency, retention, zero-retention agreements, DPAs, or the up-to-date subprocessor list — contact your account manager.
See also: [FAQ → Privacy & data](./faq.md#privacy--data) and the per-user opt-out pattern in [Setup → Disabling per init](./setup.md#disabling-per-init).
---
_**AI can make mistakes.** The AI Assistant can produce inaccurate, incomplete, or misleading content. Always review generated copy, links, prices, claims, and translations before sending or publishing. Check important info._
---
## Pricing
AI features run on a **credit-based model**: every Unlayer plan that includes AI ships with a per-period **credit cap** (applied at the workspace level — every project inside the workspace shares the same allowance), and every AI request consumes credits proportional to the work it does. Once a workspace runs out, the AI Assistant **automatically pauses** and shows an out-of-credits message in the editor until credits reset for the next billing period — or until someone with billing access chooses to top up with a credit pack.
For current plan tiers, included credits, and credit-pack pricing, see [unlayer.com/pricing](https://unlayer.com/pricing).
:::tip No surprise bills
Your AI cost is **capped at your workspace's plan credit allowance** every period. When the cap is reached, the assistant disables itself in the editor — nothing keeps charging in the background, nothing auto-bills. Adding more credits is always **opt-in** (a manual credit-pack purchase or a plan upgrade you choose).
:::
:::tip Free evaluation credits on paid plans
Workspaces on a paid plan get a fixed amount of **one-time evaluation credits** the first time AI is enabled — meant for trying out the assistant in dev or staging before committing to a credit-pack top-up. Free-plan workspaces don't receive evaluation credits — AI is a paid feature. See [Trying it in development](#trying-it-in-development).
:::
## How credits work
- A **credit** is a stable, provider-agnostic unit. The same number of credits represents the same billable value across providers.
- Credits roll up across **all AI surfaces and every project in a workspace** — text rewrites, button copy, headings, alt text, and image generation all draw from the same per-workspace credit cap.
- The Console dashboard surfaces remaining credit at the workspace level as **"X of Y credits"**, alongside a breakdown of usage over the current period.
## What a request costs
A few rough orders of magnitude (real numbers depend on the request — see [unlayer.com/pricing](https://unlayer.com/pricing) for current credit costs):
- **AI Assistant: small edit** ("translate this row", "shorten this paragraph") — tens of credits.
- **AI Assistant: full template generation** — hundreds to a few thousand credits, depending on output length.
- **Image generation** — fixed per-image cost, dozens of credits per image.
## When you run out of credits
Once a workspace uses up its credits for the current period, the AI Assistant **automatically pauses** across every project in that workspace — open assistant threads show an out-of-credits message and stop accepting new prompts, and **no further credits are spent until you choose to top up**. This is the cap working as designed: spend can't run away on you, and nothing rebills automatically.
Your options when this happens:
- **Wait until your credits reset** — credit caps roll over at the start of the next billing period.
- **Buy a credit pack** — Console → **Billing** → **Add-ons** exposes purchasable credit packs that top up the project's allowance immediately. Packs apply for the rest of the current period and roll over according to your add-on terms.
- **Upgrade the plan** — higher plans ship with larger built-in caps.
If a workspace's plan doesn't include any AI credits, the assistant stays unavailable across every project in that workspace — AI is gated behind plans that include a credit allowance.
## Trying it in development
Every Unlayer workspace — including ones you use only for dev or staging — draws from the same per-period credit cap on its plan; there's no separate "free dev tier" that doesn't count. Instead, Unlayer grants **workspaces on a paid plan** a fixed amount of one-time evaluation credits the first time AI is enabled, so embedders can prototype against the real assistant before deciding whether to top up with a credit pack.
Behavior:
- **Paid plans only.** AI is a paid feature, so free-plan workspaces don't receive evaluation credits.
- **Granted automatically** the first time `features.ai.assistant: true` resolves on a project in an eligible workspace. No flag, no extra contract, no extra card needed beyond the plan itself.
- **Single workspace bucket** — the eval credits and the plan's per-period credits share the same per-workspace allowance. Whichever is non-zero gets spent.
- **One-time** — eval credits aren't refilled. Once they're gone, the workspace follows the regular [out-of-credits flow](#when-you-run-out-of-credits): wait for the next billing period, buy a credit pack, or upgrade to a higher plan.
Dev workspaces don't get special pricing past the evaluation grant — the same rates apply, so credit costs you observe in dev are representative of production.
## Disable all AI features
```javascript
unlayer.init({
features: {
ai: false,
},
});
```
---
## Setup (legacy)
Unlayer's AI-powered editor features are controlled through the `features.ai` option passed to `unlayer.init`. You can turn the whole bundle on or off in one place, or enable AI overall while opting out of individual sub-features.
AI is **on by default** — `features.ai.enabled` (and the sub-features below) default to `true`. Pass `features.ai: false` to turn the whole bundle off for an embed.
:::note
The legacy per-element tools below (smart text, smart buttons, smart headings, magic image) are **superseded by the [AI Assistant](/ai/assistant/setup)**: when the assistant is active, it replaces these tools. They only surface when the assistant is unavailable — i.e. the project's plan doesn't include the AI Assistant feature, or it's been turned off with `features.ai.assistant: false`.
:::
## Enable or disable all AI features
Pass a boolean to toggle every AI feature at once:
```javascript
unlayer.init({
features: {
ai: true, // enable all AI features (equivalent to ai.enabled: true)
},
});
```
```javascript
unlayer.init({
features: {
ai: false, // disable every AI feature
},
});
```
## Controlling specific AI features
### AI Assistant — `assistant`
A dedicated chat panel inside the editor for full-template, page, body, row, and content-level edits. The Assistant has its own plan requirements and configuration surface — see the [AI Assistant setup guide](/ai/assistant/setup) for the full walkthrough.
```javascript
unlayer.init({
features: {
ai: {
enabled: true,
assistant: true,
},
},
});
```
When `assistant` is not enabled, each sub-feature can be set to `false` to opt out individually:
```javascript
unlayer.init({
features: {
ai: {
enabled: true,
assistant: false,
smartText: true,
smartParagraph: true,
smartButtons: false, // disable just Smart Buttons
smartHeadings: true,
magicImage: false, // disable just Magic Image
smartImageAltText: true,
},
},
});
```
### Smart Text — `smartText`
Inline AI suggestions for short text (e.g. tightening a sentence, rewriting a fragment).
### Smart Paragraph — `smartParagraph`
Generates multi-sentence paragraphs from a brief prompt or surrounding context.
### Smart Buttons — `smartButtons`
Generates optimized call-to-action button copy.
### Smart Headings — `smartHeadings`
Suggests headings tuned for the section's content.
### Magic Image — `magicImage`
Generates or recommends images based on the design context.
### Smart Image Alt Text — `smartImageAltText`
Generates accessible alt text for uploaded images.
---
## Setup
The AI Assistant is **on by default** — no feature flag is required to turn it on. It appears in the editor's mode switcher once your **plan** covers it:
- **Plan** — your project must be on a paid plan that includes the AI Assistant. Plans are managed in [Console](https://console.unlayer.com). If the project isn't on an eligible plan, the assistant stays hidden regardless of the feature flags.
- **Feature flag** — `features.ai.enabled` and `features.ai.assistant` both default to `true`. You only set them to disable the assistant for a specific embed (see [Disabling per init](#disabling-per-init)).
- **End-user identification** — the init must identify the current end-user by passing a `user` object with an `id`. Without it, the assistant stays hidden and AI requests are rejected by the server. See [End-User Identification](/builder/end-user-identification).
## Minimum config
Because the assistant defaults on, a standard init with no AI-specific flags is enough — as long as it identifies the end-user:
```javascript
unlayer.init({
id: 'editor-container',
projectId: 1234, // Replace with your numeric Unlayer Project ID
user: {
id: 'user-123', // Required for the AI Assistant
},
});
```
You can still set the flags explicitly if you prefer to be self-documenting:
```javascript
unlayer.init({
id: 'editor-container',
projectId: 1234,
features: {
ai: {
enabled: true,
assistant: true,
},
},
});
```
Don't have a Project ID yet? Sign in to the [Developer Console](https://console.unlayer.com/), create a project, and copy the ID from **Project > Settings**. The full walkthrough — adding the embed script, creating the container, and finding the Project ID — is on the [Installation](/builder/installation#where-to-get-the-project-id) page.
## Verifying it's enabled
When both checks pass, the **AI Assistant** becomes available from the editor's mode switcher; selecting it opens a dedicated chat panel on the side of the canvas.
If the assistant doesn't appear:
- The most likely cause is that the project's plan doesn't include the AI Assistant. Check the plan in [Console](https://console.unlayer.com) or contact support.
- Confirm you haven't disabled it. The flags default to `true`, but an explicit `features.ai.assistant: false` (or `features.ai.enabled: false`) in your `unlayer.init` call hides it.
- Confirm the init passes a `user` object with an `id` — the assistant is hidden when no end-user is identified. See [End-User Identification](/builder/end-user-identification).
## Testing in development
Credits are pooled at the **workspace** level — every project in a workspace shares the same per-period AI credit allowance. To make it easy to try the assistant in dev or staging, workspaces on a paid plan get a fixed amount of **one-time evaluation credits** the first time AI is enabled. Free-plan workspaces don't receive evaluation credits — AI is a paid feature. Once the eval credits are spent, you'll follow the same upgrade or credit-pack path you'd use in production. See [Pricing](./pricing.md).
## Disabling per init
Set `features.ai.assistant: false` on a specific `unlayer.init` call to hide the assistant in that editor instance only — useful for embedding the same project in multiple surfaces and wanting the assistant off in some of them.
```javascript
unlayer.init({
features: {
ai: {
enabled: true,
assistant: false,
},
},
});
```
## Related
- **[FAQ & Troubleshooting](./faq.md)** — common questions about visibility, plans, opt-out, and dev credits.
---
## MCP
:::caution Under active development
Unlayer's MCP server is **under active development** and coming soon. The endpoint, install commands, and connection instructions on this page describe the shape of what's shipping. **Interested?** Tell us how you'd use it — email [support@unlayer.com](mailto:support@unlayer.com) or use the in-product chat in [Console](https://console.unlayer.com), and we'll loop you in early.
:::
The **Model Context Protocol** (MCP) is an open standard for connecting AI clients — Claude Desktop, Cursor, Codex, Claude Code — to external tools and data sources. Unlayer's MCP server is a hosted **Streamable HTTP** endpoint at:
```
https://api.unlayer.com/mcp
```
Point any MCP-compatible client at that URL with a bearer token and you get a typed surface over your Unlayer workspace — no glue code, no custom integration.
## Tools
The first release ships **13 tools** that wrap the Cloud API, grouped by what they do:
### Templates
| Tool | What it does |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `diagnose_template` | Inspect a template for common issues — schema problems, missing alt text, WCAG AA contrast, oversized images, malformed merge tags. |
| `get_template` | Fetch one template by ID, including its design JSON. |
| `list_templates` | List templates in a project (cursor-paginated). |
### AI generation
| Tool | What it does |
| ----------------------- | ----------------------------------------------------------------------- |
| `ai_generate_design` | Generate or edit a design with the AI Assistant from a freeform prompt. |
| `import_html_to_design` | Turn an existing HTML email or page into an editable Unlayer design. |
### Render, export & convert
| Tool | What it does |
| ---------------------- | -------------------------------------------------------------------------------------- |
| `convert_design` | Convert a design between [Full and Simple schema forms](/design-schema/design-object). |
| `export_design` | Export a saved template or inline design to production-ready HTML. |
| `render_email` | Render an email design (HTML output tuned for email clients). |
| `render_preview_image` | Render a design to a PNG preview image (S3-hosted URL). |
### Workspace & account
| Tool | What it does |
| ---------------------- | ----------------------------------------------------------------------------------- |
| `get_my_subscription` | Look up the caller's plan, entitlements, and remaining AI credits. |
| `list_recent_releases` | Fetch recent Unlayer editor releases — what's new, what changed, what's deprecated. |
| `list_workspaces` | List the workspaces the caller has access to. |
### Discovery
| Tool | What it does |
| ---------------------- | ------------------------------------------------------------------------------------------------- |
| `search_documentation` | Full-text search over docs.unlayer.com — useful for grounding an AI client's answers in our docs. |
Each tool's schema is fully typed; MCP clients introspect them on connection (`tools/list`), so the AI client knows exactly what arguments each tool accepts and what it returns.
## Authentication
Every request must carry a `Bearer` token in the `Authorization` header. The MCP server accepts the same three token types as the Cloud API: **API Key** (`unlayer_sk_*`), **Personal Access Token** (`unlayer_pat_*`), or an **OAuth 2.1 access token** (handled automatically by directory-listed MCP clients like Claude Desktop, via PKCE + Dynamic Client Registration).
See **[Authentication](/server/authentication)** for step-by-step instructions on how to get each token type from Console.
## Connecting from an MCP client
### Claude Desktop
Add Unlayer as a Connector from the Connectors Directory (once listed) or manually with:
```json
{
"mcpServers": {
"unlayer": {
"url": "https://api.unlayer.com/mcp"
}
}
}
```
Claude Desktop walks the user through the OAuth flow on first use.
### Cursor / Codex / Claude Code
Any client that supports MCP over Streamable HTTP can connect with the same URL plus a bearer token. For local development and CI, an API key is the simplest path:
```bash
export UNLAYER_API_KEY=unlayer_sk_...
```
## Production guarantees
- **Origin allowlist** — the server enforces an allowed-origin list (Anthropic Connectors Directory requirement).
- **Request limits** — 1 MiB request body cap, 240s upstream timeout (sized for AI-heavy tools like `ai_generate_design`).
- **Tool-result truncation** — results larger than 140K characters are truncated (under Anthropic's 150K cap).
- **Header redaction** — `Authorization`, `Cookie`, and `X-Unlayer-Project-Id` are redacted from server logs.
## Related
- **[Cloud API](/server/cloud-api/reference)** — the REST surface every MCP tool wraps.
- **[Skills](/ai/skills)** — packaged context for AI coding agents that work alongside MCP.
- **[SDK](/sdk)** — alternative way to call the same endpoints from code.
- **[CLI](/cli)** — terminal tool for managing templates and projects.
- [Model Context Protocol specification](https://modelcontextprotocol.io/) — upstream protocol docs.
---
## How Skills Work
This page explains the technical architecture behind Unlayer Skills — how they're structured, how AI tools discover them, and how the routing system works.
## Skill Structure
Each skill is a self-contained Markdown file (`SKILL.md`) inside its own folder:
```
unlayer-skills/
├── unlayer/ # Router skill
│ └── SKILL.md
├── unlayer-integration/ # Framework setup
│ └── SKILL.md
├── unlayer-custom-tools/ # Custom tool building
│ └── SKILL.md
├── unlayer-export/ # Export and design management
│ ├── SKILL.md
│ └── references/
│ └── design-json.md # TypeScript types for design JSON
└── unlayer-config/ # Editor configuration
├── SKILL.md
└── references/
├── feature-flags.md
├── file-storage.md
└── security.md
```
Each `SKILL.md` file contains:
- **Frontmatter** with the skill name and description
- **Overview** of what the skill covers
- **Complete code examples** that AI can use as templates
- **Reference tables** for API options, widgets, and configuration
- **Common mistakes** and troubleshooting guides
- **Links** to official Unlayer documentation
## How Routing Works
The `unlayer` skill acts as a router. When you ask a question, your AI assistant reads the router skill first, which contains a mapping table:
| User Question | Routed To |
| -------------------------------------- | ---------------------- |
| "Add Unlayer to my React app" | `unlayer-integration` |
| "Create a custom drag-and-drop tool" | `unlayer-custom-tools` |
| "Export HTML" / "Save the design" | `unlayer-export` |
| "Set up merge tags" / "Configure HMAC" | `unlayer-config` |
The AI assistant then reads the appropriate sub-skill for detailed guidance. For complex tasks that span multiple skills, the router recommends a sequence:
1. `unlayer-integration` — add the editor
2. `unlayer-config` — customize behavior
3. `unlayer-export` — save and export
4. `unlayer-custom-tools` — build custom blocks
## How AI Tools Discover Skills
When you run `npx skills add unlayer/unlayer-skills`, the CLI places skill files where your AI tool expects them:
| Tool | Location |
| -------------- | ------------------- |
| Cursor | `.agents/skills/` |
| Claude Code | `.claude/skills/` |
| GitHub Copilot | `.agents/skills/` |
| Windsurf | `.windsurf/skills/` |
| Codex | `.agents/skills/` |
Your AI tool automatically reads files from its expected location before generating responses. No additional configuration is needed.
## Keeping Skills Current
Skills include a version check mechanism. The router skill's `SKILL.md` reads its own file modification time and prompts the AI to suggest an update if the file is more than 30 days old:
```bash
npx skills update
```
This pulls the latest skill files from the [GitHub repository](https://github.com/unlayer/unlayer-skills), ensuring your AI assistant always has current API information.
## Example Prompts
Here are some prompts you can try after installing the skills:
**Integration:**
- _"How do I add Unlayer to my React app?"_
- _"Set up the Unlayer editor in my Angular project"_
**Custom Tools:**
- _"Create a custom product card tool with an image, title, price, and buy button"_
- _"How do I create a custom property editor with a range slider?"_
**Export:**
- _"Export my design as HTML and save the design JSON"_
- _"Set up auto-save that triggers when the design changes"_
**Configuration:**
- _"Set up merge tags for my email templates"_
- _"Configure HMAC security for user identification"_
---
## What Are Skills?
Skills are structured knowledge files that give AI coding assistants expert-level understanding of a specific SDK or tool. Instead of relying on training data that may be outdated or incomplete, your AI reads the skill files directly — getting accurate API signatures, working code examples, and best practices.
## The Problem
AI coding assistants are trained on public data, but that data has limitations:
- **Outdated APIs** — training data may reference deprecated methods or old package versions
- **Missing context** — the AI doesn't know your project's specific Unlayer configuration
- **Hallucinated patterns** — without precise knowledge, AI may generate plausible-looking but broken code
- **Generic advice** — responses lack Unlayer-specific best practices like email-safe HTML or proper design JSON handling
## The Solution
Unlayer Skills solve this by providing your AI assistant with authoritative, version-current documentation as context. When you install the skills into your project, your AI coding tool reads them automatically before generating responses.
The result: instead of guessing, your AI assistant produces code that follows Unlayer's actual API, uses the correct package versions, and applies proven patterns for common tasks like save/load, export, and custom tools.
## Available Skills
Unlayer provides five skills organized as a router and four domain-specific sub-skills:
- **`unlayer`** (Router) — the main entry point that automatically delegates your question to the right sub-skill
- **`unlayer-integration`** — framework setup for React, Vue, Angular, and plain JavaScript, including component mounting, editor access patterns, and multiple instances
- **`unlayer-custom-tools`** — building custom drag-and-drop content blocks with renderers, exporters, property editors, and validators
- **`unlayer-export`** — exporting HTML, PDF, images, and ZIP; saving and loading design JSON; auto-save patterns; Cloud API
- **`unlayer-config`** — feature flags, appearance, merge tags, HMAC security, file storage, localization, and validation
## Supported AI Tools
Skills work with any AI coding assistant that supports custom context files, including:
- [Cursor](./../getting-started/using-with-cursor)
- [Claude Code](./../getting-started/using-with-claude-code)
- [Codex](./../getting-started/using-with-codex)
- GitHub Copilot, Windsurf, and [others](./../getting-started/using-with-other-tools)
## Resources
- [GitHub Repository](https://github.com/unlayer/unlayer-skills) — full source for all skills
- [How Skills Work](./how-skills-work) — the technical details
---
## Install Skill
Install Unlayer Skills into your project so your AI coding assistant can provide expert-level Unlayer guidance.
## Installation
Run the following command in your project directory:
```bash
npx skills add unlayer/unlayer-skills
```
The CLI automatically detects your AI tool and installs the skill files in the correct location.
## What Gets Installed
The command installs five skill folders into your project:
| Folder | Skill | Description |
| ----------------------- | ------------- | --------------------------------------------- |
| `unlayer/` | Router | Delegates questions to the right sub-skill |
| `unlayer-integration/` | Integration | React, Vue, Angular, and plain JS setup |
| `unlayer-custom-tools/` | Custom Tools | Building custom drag-and-drop blocks |
| `unlayer-export/` | Export | HTML, PDF, image export and design management |
| `unlayer-config/` | Configuration | Features, appearance, security, and more |
## Update Skills
Pull the latest version of all installed skills:
```bash
npx skills update
```
:::tip Keep Skills Updated
Skills are updated when Unlayer releases new features or API changes. Run `npx skills update` periodically to ensure your AI assistant has the most current information.
:::
## Try It Out
After installing, open your AI assistant's chat and try a prompt:
```
How do I add Unlayer to my React app?
```
Your AI assistant should respond with a complete, working integration — including proper ref handling, ready-event listeners, and save/load patterns.
## Tool-Specific Guides
For detailed instructions on configuring specific AI tools:
- [Using with Cursor](./using-with-cursor)
- [Using with Claude Code](./using-with-claude-code)
- [Using with Codex](./using-with-codex)
- [Using with Other Tools](./using-with-other-tools) (GitHub Copilot, Windsurf, and more)
---
## Using with Claude Code
[Claude Code](https://code.claude.com/docs/en/skills) is Anthropic's agentic coding tool that runs in your terminal. It reads skill files from `.claude/skills/`, and the `skills` CLI installs there directly when it detects Claude Code — no extra setup needed.
## Install
```bash
npx skills add unlayer/unlayer-skills --agent claude-code
```
Skills are installed to `.claude/skills/` in your project directory.
## Verify
Check that the skills are available:
```bash
ls .claude/skills/unlayer*
```
You should see folders: `unlayer/`, `unlayer-integration/`, `unlayer-custom-tools/`, `unlayer-export/`, and `unlayer-config/`.
## How It Works in Claude Code
Claude Code reads skill files as additional context before responding to your prompts. The skills provide Claude with:
- Accurate API signatures for `unlayer.init()`, `exportHtml()`, `loadDesign()`, and all other methods
- Working code examples for React, Vue, Angular, and plain JavaScript
- Email-safe HTML patterns for custom tool exporters
- Correct configuration options for merge tags, HMAC security, file storage, and more
## Example Prompts
Try these in your Claude Code session:
| Prompt | What You'll Get |
| --------------------------------------------------- | ---------------------------------------------------------------- |
| _"Add the Unlayer email editor to my React app"_ | Complete component with save/load pattern |
| _"Build a custom testimonial block for the editor"_ | registerTool with renderer, email exporter, and property editors |
| _"Export the design as HTML and save to my API"_ | exportHtml with proper design JSON handling |
| _"Set up custom image uploads to S3"_ | registerCallback with FormData upload pattern |
| _"Configure display conditions for VIP users"_ | setDisplayConditions with template engine syntax |
## Update
```bash
npx skills update
```
---
## Using with Codex
[Codex](https://developers.openai.com/codex/skills) is OpenAI's cloud-based coding agent. It reads skill files from the `.agents/skills/` directory in your project.
## Install
```bash
npx skills add unlayer/unlayer-skills --agent codex
```
Skills are installed to `.agents/skills/` in your project directory.
## Verify
Confirm the skill files are in place:
```bash
ls .agents/skills/unlayer*
```
You should see folders: `unlayer/`, `unlayer-integration/`, `unlayer-custom-tools/`, `unlayer-export/`, and `unlayer-config/`.
## How It Works in Codex
Codex reads `.agents/skills/` files and uses them as context when executing tasks. The skills provide Codex with:
- Accurate API signatures for `unlayer.init()`, `exportHtml()`, `loadDesign()`, and all other methods
- Working code examples for React, Vue, Angular, and plain JavaScript
- Email-safe HTML patterns for custom tool exporters
- Correct configuration options for merge tags, HMAC security, file storage, and more
## Example Prompts
Try these in your Codex session:
| Prompt | What You'll Get |
| --------------------------------------------------- | --------------------------------------------------------------- |
| _"Add the Unlayer email editor to my React app"_ | Complete component with save/load pattern |
| _"Create a custom product card tool"_ | Full registerTool call with renderer, exporters, and properties |
| _"Export the design as HTML and save to my API"_ | exportHtml with proper design JSON handling |
| _"Set up custom image uploads to S3"_ | registerCallback with FormData upload pattern |
| _"Configure HMAC security for user identification"_ | Server-side signature generation + client-side init |
## Update
```bash
npx skills update
```
---
## Using with Cursor
[Cursor](https://cursor.com/docs/skills) reads skill files automatically from the `.agents/skills/` directory in your project. Once installed, every AI chat and Cmd+K action has access to Unlayer expertise.
## Install
```bash
npx skills add unlayer/unlayer-skills --agent cursor
```
Skills are installed to `.agents/skills/` in your project directory.
## Verify
Confirm the skill files are in place:
```bash
ls .agents/skills/unlayer*
```
You should see folders: `unlayer/`, `unlayer-integration/`, `unlayer-custom-tools/`, `unlayer-export/`, and `unlayer-config/`.
## How It Works in Cursor
Cursor automatically reads `.agents/skills/` files and includes them as context when you:
- **Chat** (Cmd+L) — ask questions like _"How do I add Unlayer to my React app?"_
- **Inline edit** (Cmd+K) — generate or modify code with Unlayer-aware suggestions
- **Agent mode** — let the AI handle multi-step Unlayer integration tasks
The router skill (`unlayer/SKILL.md`) identifies the topic and delegates to the appropriate sub-skill, so you get focused, accurate answers regardless of how you phrase your question.
## Example Prompts
Try these in Cursor's chat:
| Prompt | What You'll Get |
| -------------------------------------------- | --------------------------------------------------------------- |
| _"Add Unlayer email editor to my React app"_ | Complete component with ref handling, ready event, save/load |
| _"Create a custom product card tool"_ | Full registerTool call with renderer, exporters, and properties |
| _"Set up auto-save for the editor"_ | Debounced design:updated listener with exportHtml |
| _"Configure HMAC security"_ | Server-side signature generation + client-side init |
| _"Add merge tags for my email templates"_ | setMergeTags call with nested groups and loops |
## Update
```bash
npx skills update
```
---
## Using with Other Tools
Unlayer Skills work with any AI coding assistant that supports custom context files. This page covers setup for GitHub Copilot, Windsurf, and manual installation for other tools.
## GitHub Copilot
```bash
npx skills add unlayer/unlayer-skills --agent copilot
```
Skills are installed to `.agents/skills/` in your project directory. GitHub Copilot uses these files as additional context when generating responses.
**Verify:**
```bash
ls .agents/skills/unlayer*
```
## Windsurf
```bash
npx skills add unlayer/unlayer-skills --agent windsurf
```
Skills are installed to `.windsurf/skills/` in your project directory. Windsurf picks them up automatically as context for your AI conversations.
**Verify:**
```bash
ls .windsurf/skills/unlayer*
```
## Manual Installation
The skills are plain Markdown files hosted on GitHub. You can add them to any AI tool that supports custom instructions or context files:
1. Clone or download the repository:
```bash
git clone https://github.com/unlayer/unlayer-skills.git
```
2. Copy the skill folders into your project's skills directory (use the location your AI tool expects — see the table in [How Skills Work](../concepts/how-skills-work#how-ai-tools-discover-skills)):
```bash
mkdir -p .agents/skills
cp -r unlayer-skills/unlayer* .agents/skills/
```
The skill folders are: `unlayer/`, `unlayer-integration/`, `unlayer-custom-tools/`, `unlayer-export/`, and `unlayer-config/`. Each contains a self-contained `SKILL.md` file.
## Update
For all tools, pull the latest skills with:
```bash
npx skills update
```
---
## Skills
Unlayer provides official Skills for AI coding assistants like Cursor, Claude Code, Codex and others. Once installed, your AI assistant gains expert-level knowledge of the Unlayer SDK — giving you accurate, up-to-date answers instead of hallucinated or outdated patterns.
## Quick Install
```bash
npx skills add unlayer/unlayer-skills
```
This installs the skills into your project so your AI coding tool can read them automatically. See [Install Skill](/ai/skills/getting-started/install-skill) for detailed setup instructions.
## What's Included
| Skill | Description |
| ---------------------- | ---------------------------------------------------------------------- |
| `unlayer` | Router — automatically delegates to the right sub-skill |
| `unlayer-integration` | Add Unlayer to React, Vue, Angular, or plain JavaScript |
| `unlayer-elements` | Build emails, pages, and documents in code with React components |
| `unlayer-custom-tools` | Build custom drag-and-drop tools and property editors |
| `unlayer-export` | Export HTML, PDF, images, or save/load designs |
| `unlayer-config` | Configure features, appearance, merge tags, security, and file storage |
## Next Steps
- [What Are Skills?](/ai/skills/concepts/what-are-skills) — understand what skills are and why they matter
- [Install Skill](/ai/skills/getting-started/install-skill) — get set up in under a minute
- [GitHub Repository](https://github.com/unlayer/unlayer-skills) — view the source and contribute
---
## AI Template Importer
The AI Template Importer turns an existing design into an editable Unlayer template in one click. Bring an HTML email or a screenshot of the page you want to recreate, and the importer rebuilds the layout — rows, columns, text, buttons, images, colors, and links — as a fully editable template, ready to refine in the builder.
It's available in the [Unlayer Console](https://console.unlayer.com), inside any project's **Templates** area.
:::success Included in your AI credits
The AI Template Importer is part of your plan's AI features and uses your existing AI credit allowance — the same pool that powers the AI Assistant and other AI tools. No separate add-on required.
:::
## What you can import
The importer accepts two kinds of input and produces a complete, editable template:
- **HTML email.** Bring an existing email — exported from another tool, hand-coded, or pulled from your inbox. The importer reads the layout, preserves your copy, links, fonts, and colors, and quietly skips elements like tracking pixels and "View in browser" links that don't belong in your editable template.
- **Screenshot or image.** Have a design in mind but no source HTML? Upload a screenshot of any email or page you'd like to recreate. The importer reads the visual layout and rebuilds it from scratch so you can take it from there.
Once imported, the template behaves like any other — rearrange rows, edit copy, swap images, and adjust styles directly in the builder.
## How to import a template
1. In the console, open the **Templates** tab for your project (available for **Emails**, **Pages**, and **Documents**).
2. Click **AI Template Importer** in the top-right corner.
3. Drag and drop your file, or click to choose one:
- **HTML files:** `.html`, `.htm`
- **Images:** `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`
- **Max file size:** 5 MB
4. Give your template a name and, if you'd like, choose a folder to save it in.
5. Click **Import** and let the importer work — you'll see progress updates as it goes. A typical import takes under a minute; longer or more detailed designs may take a bit more time.
When it's done, you'll land directly in the editor with your new template open, ready to edit, polish, and use.
## Credits & limits
Each import uses credits from your project's **AI credit allowance**, the same allowance shared by other AI features on your plan. You don't need to set anything up — if your plan includes AI, the importer is ready to use.
### What an import costs
Because the importer builds a complete template, each run uses meaningfully more credits than a small text edit or single suggestion. The exact amount depends on:
- **How large the source is.** Longer emails and denser screenshots take more work to rebuild.
- **Whether you import HTML or an image.** Image imports tend to cost a bit more, since the importer reads the design visually.
For your plan's included credits, available credit packs, and pricing details, see the [pricing page](https://unlayer.com/pricing).
### If you run out of credits
If your project has used up its AI credits for the current period, the importer will let you know before running. From there you can:
- **Wait for your credits to reset** at the start of your next billing period.
- **Add a credit pack** anytime from _Console → Billing → Add-ons_ to keep going right away.
- **Upgrade your plan** for a larger built-in allowance.
If your plan doesn't include AI features at all, the **AI Template Importer** button won't appear — upgrade your plan to enable AI tools.
### Where to see your usage
All AI activity — imports, the AI Assistant, and other AI features — shows up together in your project's **Usage Insights** view in the console as a single AI credits total, so you always know where you stand against your allowance.
## Help us make it better
After each import you'll have a chance to rate how the result turned out. Your ratings and comments go straight to our team and directly shape how the importer improves over time — it's the fastest way to flag a layout that didn't quite work, a color that came out off, or something missing entirely.
For broader feedback or recurring quality issues across many templates, reach out to your account manager or [Unlayer support](https://unlayer.com/support).
## Tips & things to know
A few things that help you get the best results:
- **Imports always create a full template.** If you'd like to edit just one section of an existing design, the **AI Assistant** inside the builder is a better fit.
- **5 MB file limit.** If your HTML or screenshot is too large, try compressing the image or removing inline base64 attachments from the HTML.
- **Image imports use placeholders.** Since screenshots don't carry the original image files, the importer fills image spots with placeholders — you can swap in your real images right in the editor.
- **Expect small adjustments.** Especially with screenshot imports, the importer gets you most of the way there, but final polish — exact colors, spacing, fonts — is best done with a quick pass in the builder.
- **One file at a time.** Import templates one at a time. For a larger library, run the importer for each file in turn.
If an import doesn't come out the way you expected, try importing again or with a slightly different source file — and please leave a rating so we can keep improving.
## Related
- **[Pricing](https://unlayer.com/pricing)** — plans, included AI credits, and credit packs.
- **[Templates](/builder/latest/templates/)** — organizing, sharing, and managing your templates.
---
## Console
The **Unlayer Console** is the hosted dashboard at [console.unlayer.com](https://console.unlayer.com). It's where teams manage their Unlayer projects, organize templates, configure billing, and run features that aren't part of the embedded builder SDK.
While [Builders](/builder/installation) run inside your application and [Cloud API](/server/cloud-api/reference) runs on your backend, the Console is the **operator-facing surface** — used by your team, not your end users.
## What lives in the Console
- **Projects** — create, configure, and rotate API keys for each Unlayer integration.
- **Templates** — central library of Email, Page, and Document templates per project, with folders, versions, and permissions.
- **Billing & Usage** — plan management, AI credit caps, credit packs, and per-period usage insights.
- **AI Template Importer** — convert an existing HTML email or screenshot into an editable Unlayer template. See [AI Template Importer](/ai/template-importer).
## When to use the Console
Use the Console when you're acting as an **operator** of Unlayer rather than as an integrator:
- Importing or seeding template libraries before launching to your end users.
- Reviewing AI credit consumption across the team.
- Managing folders and permissions across a shared template catalog.
- Configuring branding, API keys, or plan-level settings.
For end-user-facing flows (embedded editing, AI Assistant inside the editor, server-side export), see [Builders](/builder/installation) and [Server](/server/cloud-api).
---
## Custom Backend
By default, Unlayer stores collaboration threads and comments for you. You can store them in your own database instead — to own and retain the data, for data residency, compliance, or integration reasons. Just register your own storage and the builder uses it in place of Unlayer's cloud, whether you use Unlayer's cloud or run on-premise.
- **Providers** load threads and comments (reads).
- **Callbacks** persist changes (writes).
When you register the `collaborationThreads` provider, the builder calls it instead of Unlayer's cloud storage — likewise for the write callbacks. This works with both Unlayer's cloud and on-premise setups.
```javascript
unlayer.init({
id: 'editor',
projectId: 1234,
designId: 'design-5678',
user: { id: 76, name: 'Jane Doe' },
features: { collaboration: true },
});
```
:::info
When you use Unlayer's cloud, providers and callbacks **override** the cloud storage when present, and the builder falls back to the cloud when they're absent. In an **on-premise** setup (with no Unlayer cloud), they're **required** — the Comments button stays hidden until you register the `collaborationThreads` provider.
:::
---
## Providers
Register each provider with `unlayer.registerProvider(name, handler)`. The handler receives `params` and a `done` function to call with your result.
| Provider | Params | Expected result |
| ----------------------------- | ------------------------- | ------------------------------------ |
| `collaborationThreads` | `{ projectId, designId }` | `{ success: true, data: Thread[] }` |
| `collaborationThreadComments` | `{ threadId }` | `{ success: true, data: Comment[] }` |
```javascript
unlayer.registerProvider('collaborationThreads', async (params, done) => {
// params = { projectId, designId }
const threads = await fetchThreadsFromYourBackend(params);
done({ success: true, data: threads });
});
unlayer.registerProvider(
'collaborationThreadComments',
async (params, done) => {
// params = { threadId }
const comments = await fetchCommentsFromYourBackend(params.threadId);
done({ success: true, data: comments });
},
);
```
To make the builder re-fetch threads — for example after your backend receives new comments from another user — call:
```javascript
unlayer.reloadProvider('collaborationThreads');
```
---
## Callbacks
Register each callback with `unlayer.registerCallback(name, handler)`. The builder calls them when the user makes a change; your handler persists it and calls `done` with the saved entity. On failure, call `done({ success: false, error })` and the builder will discard the change.
| Callback | Payload | Expected result |
| -------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `collaboration:thread:added` | `{ thread }` — includes `designId`, `itemId`, `type`, `text`, `user` | `{ success: true, thread }` — with server-assigned `id`, `firstComment`, timestamps |
| `collaboration:thread:modified` | `{ threadId, data }` — e.g. `{ status: 'resolved' }` | `{ success: true, thread }` |
| `collaboration:thread:removed` | `{ threadId }` | `{ success: true }` |
| `collaboration:comment:added` | `{ comment }` — includes `threadId`, `text`, `user` | `{ success: true, comment }` — with server-assigned `id` and timestamps |
| `collaboration:comment:modified` | `{ commentId, data, threadId }` | `{ success: true, comment }` |
| `collaboration:comment:removed` | `{ commentId, threadId }` | `{ success: true }` |
**Example: creating a thread**
```javascript
unlayer.registerCallback(
'collaboration:thread:added',
async ({ thread }, done) => {
if (!thread?.text || !thread.user?.id) {
done({ success: false, error: new Error('Invalid thread') });
return;
}
// Persist the thread and its first comment, assign IDs and timestamps
const savedThread = await saveThreadToYourBackend(thread);
done({ success: true, thread: savedThread });
},
);
```
When creating a thread, your backend should also create its **first comment** from the thread's `text` and `user`, and return the thread with `firstComment`, `commentCount`, `status: 'open'`, and `createdAt` / `updatedAt` set.
---
## Data model
### Thread
| Field | Type | Description |
| ------------------------- | ------------------------------------------------ | --------------------------------------------------------- |
| `id` | `string \| number` | Unique thread ID (assigned by your backend). |
| `projectId` | `string \| number` | The project the design belongs to. |
| `designId` | `string` | The design the thread is on. |
| `itemId` | `string` | The ID of the design component the thread is anchored to. |
| `user` | `User` | The user who started the thread. |
| `type` | `'feedback' \| 'idea' \| 'question' \| 'urgent'` | Thread category. |
| `status` | `'open' \| 'resolved'` | Thread state. |
| `commentCount` | `number` | Total comments, including the first. |
| `firstComment` | `Comment` | The comment that started the thread. |
| `createdAt` / `updatedAt` | `string` | ISO 8601 timestamps. |
### Comment
| Field | Type | Description |
| ------------------------- | ------------------ | --------------------------------------------- |
| `id` | `string \| number` | Unique comment ID (assigned by your backend). |
| `threadId` | Thread `id` | The thread this comment belongs to. |
| `user` | `User` | The comment author. |
| `text` | `string` | The comment body (plain text). |
| `createdAt` / `updatedAt` | `string` | ISO 8601 timestamps. |
### User
| Field | Type | Description |
| -------- | -------- | ----------------------------------------- |
| `id` | `string` | Stable unique user ID. |
| `name` | `string` | Display name. |
| `avatar` | `string` | Image URL; initials are shown when empty. |
---
## Tips
- Sort threads by `updatedAt` descending and comments by `createdAt` so the panel shows the latest activity first.
- Bump the thread's `updatedAt` when a reply is added so it surfaces at the top of the list.
- When the last comment of a thread is removed, remove the thread as well.
- Enforce ownership server-side: only allow users to modify or remove their own threads and comments.
---
## Team Collaboration
Team Collaboration turns the builder into a **shared review workspace**. Instead of pushing design feedback into email chains, screenshots, and chat threads, your users discuss a design right on the design itself: anyone on the team can start a conversation anchored to the exact component it's about — a row, an image, a text block — and teammates reply in threads, flag items as feedback, ideas, questions, or urgent, and resolve discussions as the work gets done.
The entire review cycle — comment, discuss, prioritize, resolve — happens inside your application, with full context and a clear record of what's open and what's settled.
---
## How it works
When collaboration is enabled, a **Comments** button appears in the builder's action bar, with a badge showing the number of open threads. Clicking it puts the builder into **Collaboration Mode**:
- A **Comments panel** lists every thread on the current design.
- Selecting any component opens a popover where users can read its thread or start a new one.
- Threads can be replied to, categorized, resolved, and filtered.
There are two ways into a conversation: the **Comments** button in the top action bar (which also carries the open-thread badge), and the **comment** button on a selected component's toolbar.
See [Using Comments](./using-comments.md) for the full walkthrough of the user experience.
---
## Key concepts
| Concept | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Thread** | A conversation anchored to one design component (`itemId`). Each thread belongs to a design (`designId`) and has a type and a status. |
| **Comment** | A single message inside a thread. The first comment starts the thread; later comments are replies. |
| **Type** | Each thread is categorized as `feedback`, `idea`, `question`, or `urgent`, each with its own icon. |
| **Status** | Threads are `open` or `resolved`. Resolving a thread hides it from the default list; it can be reopened later. |
| **User** | The author of each comment — the `user` you pass to `unlayer.init()`, with their name and avatar. |
---
## Where comments are stored
By default, threads and comments are stored in Unlayer's cloud database and scoped to your project and design — no backend work required.
If you'd rather own and retain the data yourself, you can store threads and comments in your own database by registering providers and callbacks. See [Custom Backend](./custom-backend.md).
---
## Next steps
- [Setup](./setup.md) — enable the feature and configure `unlayer.init()`.
- [Using Comments](./using-comments.md) — what your users see and can do.
- [Custom Backend](./custom-backend.md) — store threads and comments in your own database.
---
## Setup(Collaboration)
Team Collaboration is configured entirely through `unlayer.init()`. The Comments button appears in the action bar only when all of the requirements below are met.
---
## Requirements
| Requirement | How |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| Plan with Team Collaboration | Your project's plan must include the Team Collaboration feature. See [pricing](https://unlayer.com/pricing). |
| Feature flag | Pass `features: { collaboration: true }` — the feature is off by default. |
| Project ID | Pass your `projectId` so threads are scoped to your project. |
| Design ID | Pass a `designId` that is stable and unique per design. Threads are attached to it — without it, the feature stays hidden. |
| User identity | Pass a `user` with a stable `id`. Users can browse threads anonymously, but creating comments requires an identified user. |
---
## Example
```javascript
unlayer.init({
id: 'editor',
projectId: 1234,
designId: 'design-5678', // your unique, stable ID for this design
user: {
id: 76,
name: 'Jane Doe',
email: 'jane.doe@acme.com',
avatar: 'https://your-app.com/avatars/jane.png',
signature: 'e0bdb1f5cc...', // recommended: HMAC identity verification
},
features: {
collaboration: true,
},
});
```
Once the builder session starts, open threads for the design load automatically and the Comments button shows a badge with the open-thread count.
---
## Configuration reference
### `designId`
Threads are stored against the `designId`, not the design content. Use the same identifier every time you load that design — typically your database's primary key for it. If the `designId` changes between sessions, previous comments will not appear.
### `user`
The `user` object identifies the comment author:
| Property | Required | Description |
| ----------- | ----------- | ----------------------------------------------------------------------------------------------------------------------- |
| `id` | Yes | A stable, unique identifier for the user. Used for thread ownership — users can only edit or delete their own comments. |
| `name` | Recommended | Displayed next to each comment. |
| `email` | No | The user's email address. |
| `avatar` | No | Image URL shown beside comments. When omitted, initials from `name` are shown instead. |
| `signature` | Recommended | HMAC signature of the user `id`, generated on your server. |
See [End-User Identification](../advanced-configuration/end-user-identification.md) for details on the `user` object and how to generate the `signature` for identity verification.
### Storing data in your own backend
Threads and comments are stored in Unlayer's cloud database by default. To keep them in your own database instead — whether for data ownership, residency, or compliance — register providers and callbacks; the builder then uses your storage in place of the cloud. It works the same whether you use Unlayer's cloud or run on-premise.
In an **on-premise** setup, Unlayer's cloud is not used at all, so a custom backend becomes mandatory: the Comments button stays hidden until you register a `collaborationThreads` provider. See [Custom Backend](./custom-backend.md).
---
## Troubleshooting
The Comments button does not appear when any requirement is missing. Check, in order:
1. `features.collaboration` is `true`.
2. Your plan includes Team Collaboration.
3. `designId` is set.
4. On-premise: the `collaborationThreads` provider is registered.
---
## Using Comments
This page walks through the experience your users get once Team Collaboration is [set up](./setup.md).
---
## Entering Collaboration Mode
The **Comments** button in the builder's action bar shows a badge with the number of open threads. Clicking it switches the builder into Collaboration Mode.
- The **Comments panel** opens, listing all threads on the design — newest activity first.
- Design editing controls are disabled while the mode is active; users browse and discuss instead of editing.
- While the mode is active, the action-bar button reads **Collaboration Mode** — click it again, or the **Close Comments** (×) button in the Comments panel header, to exit.
If the design has no threads yet, the panel prompts the user to select a component to add the first comment.
---
## Starting a thread
In Collaboration Mode, clicking any component of the design — a text block, an image, a row — opens a comment popover for it:
1. Pick a comment type (see below).
2. Type the comment in the **Add a comment** field.
3. Submit. The thread is created and anchored to that component.
Each comment shows the author's name, avatar (or initials), and a timestamp.
### Comment types
Every thread carries one of four types, shown as an icon on the thread:
| Type | Intent |
| ------------ | ------------------------------------------ |
| **Feedback** | General review notes. |
| **Idea** | Suggestions and alternatives. |
| **Question** | Something that needs an answer. |
| **Urgent** | Must be addressed before the design ships. |
---
## Replying
Opening an existing thread — from the panel list or by clicking its component — shows the full conversation in a popover with a **Leave a reply** field. The thread's reply count updates in the panel list (for example "1 reply" or "No replies").
---
## Resolving threads
Each thread popover has a **Resolve** button. Resolving a thread:
- Marks it `resolved` and hides it from the default panel list.
- Removes it from the open-thread badge count.
- Disables new replies while resolved.
Resolved threads aren't deleted — they can be found with the **Resolved** filter and reopened at any time.
---
## Filtering
The Comments panel can filter threads by:
- **All** — every open thread on the design.
- **Only yours** — threads started by the current user.
- **Resolved** — threads that have been resolved.
- **Type** — show only a specific comment type (feedback, idea, question, urgent).
---
## Permissions
- Browsing threads works for any user of the builder; **creating** comments requires an identified `user` (see [Setup](./setup.md)).
- Users can only edit or delete **their own** threads and comments.
- Deleting a thread's last remaining comment removes the thread itself.
---
## From 1.309.4 to 1.339.0
## Text Tool Deprecated
The **Text** tool has been deprecated in favor of the new **Paragraph** tool. Existing text blocks created with the Text tool will continue to work as expected.
### Key Differences
| | Text Tool (deprecated) | Paragraph Tool (new) |
| :------------ | :--------------------- | :----------------------- |
| Tool name | `text` | `paragraph` |
| Text property | `text` (HTML string) | `textJson` (JSON string) |
### Default Value Format Change
The Text tool used an HTML string for its `text` property:
```javascript
// Text tool (deprecated)
unlayer.init({
tools: {
text: {
properties: {
text: {
value:
'This is a new Text block. Change the text.',
},
},
},
},
});
```
The Paragraph tool uses a JSON string for its `textJson` property:
```javascript
// Paragraph tool (new)
unlayer.init({
tools: {
paragraph: {
properties: {
textJson: {
value:
'{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","text":"This is a new Paragraph block. Change the text.","type":"extended-text","version":1}],"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0}],"format":"","indent":0,"type":"root","version":1}}',
},
},
},
},
});
```
### Migration Options
If you're currently using the Text tool, you have two options:
1. **Migrate to Paragraph**: Update your configuration to use the `paragraph` tool with the `textJson` property format.
2. **Lock your builder version**: Pin your builder to a version where the Text tool is still fully supported. See the version locking documentation for details.
---
## From 1.339.0 to 1.364.0
## Text Editor Update for Heading and Button Tools
The inline text editor for **Heading** and **Button** tools has been updated. Existing headings and buttons in saved designs will continue using the legacy editor, while new ones dropped into designs will use the updated editor.
## API Changes
For both the **Heading** and **Button** tools, the `text` property has been renamed to `textJson`. The value format changed from a plain string to a JSON structure.
### Heading Tool
**Before (1.339.0):**
```javascript
unlayer.init({
tools: {
heading: {
properties: {
text: {
value: 'This is a heading',
},
},
},
},
});
```
**After (1.364.0):**
```javascript
unlayer.init({
tools: {
heading: {
properties: {
textJson: {
value:
'{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"This is a heading","type":"extended-text","version":1}],"format":"","indent":0,"type":"extended-paragraph","version":1,"textFormat":0,"isInlineTool":true}],"format":"","indent":0,"type":"root","version":1}}',
},
},
},
},
});
```
### Button Tool
**Before (1.339.0):**
```javascript
unlayer.init({
tools: {
button: {
properties: {
text: {
value: 'Click here',
},
},
},
},
});
```
**After (1.364.0):**
```javascript
unlayer.init({
tools: {
button: {
properties: {
textJson: {
value:
'{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Click here","type":"extended-text","version":1}],"format":"","indent":0,"type":"extended-paragraph","version":1,"textFormat":0,"isInlineTool":true}],"format":"","indent":0,"type":"root","version":1}}',
},
},
},
},
});
```
## JSON Structure Reference
The new `textJson` value follows this structure:
```json
{
"root": {
"children": [
{
"children": [
{
"detail": 0,
"format": 0,
"mode": "normal",
"style": "",
"text": "Your text here",
"type": "extended-text",
"version": 1
}
],
"format": "",
"indent": 0,
"type": "extended-paragraph",
"version": 1,
"textFormat": 0,
"isInlineTool": true
}
],
"format": "",
"indent": 0,
"type": "root",
"version": 1
}
}
```
To customize the text content, replace the `"text"` field value inside the `extended-text` node.
---
## From 1.382.0 to Latest
## Text Editor Update for Table Tool
The inline text editor for the **Table** tool has been updated. Existing tables in saved designs will continue using the legacy editor, while new ones dropped into designs will use the updated editor.
### API Changes
Each cell in the Table tool's `headers`, `rows`, and `footers` arrays now includes a `textJson` property alongside the existing `text` property. The `textJson` value uses a JSON structure, while `text` remains an HTML string.
**Before (1.382.0):**
```javascript
unlayer.init({
tools: {
table: {
properties: {
table: {
value: {
headers: [
{
cells: [
{ text: 'Add header text', width: 50 },
{ text: '', width: 50 },
],
height: 50,
},
],
rows: [
{
cells: [
{ text: 'Add text', width: 50 },
{ text: '', width: 50 },
],
height: 50,
},
],
footers: [
{
cells: [
{ text: '', width: 50 },
{ text: '', width: 50 },
],
height: 50,
},
],
},
},
},
},
},
});
```
**After (Latest):**
```javascript
unlayer.init({
tools: {
table: {
properties: {
table: {
value: {
headers: [
{
cells: [
{
textJson:
'{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Add header text","type":"extended-text","version":1}],"format":"","indent":0,"type":"extended-paragraph","version":1,"textFormat":0}],"format":"","indent":0,"type":"root","version":1}}',
text: 'Add header text',
width: 50,
},
{
textJson:
'{"root":{"children":[{"children":[],"format":"","indent":0,"type":"extended-paragraph","version":1,"textFormat":0}],"format":"","indent":0,"type":"root","version":1}}',
text: '',
width: 50,
},
],
height: 50,
},
],
rows: [
{
cells: [
{
textJson:
'{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Add text","type":"extended-text","version":1}],"format":"","indent":0,"type":"extended-paragraph","version":1,"textFormat":0}],"format":"","indent":0,"type":"root","version":1}}',
text: 'Add text',
width: 50,
},
{
textJson:
'{"root":{"children":[{"children":[],"format":"","indent":0,"type":"extended-paragraph","version":1,"textFormat":0}],"format":"","indent":0,"type":"root","version":1}}',
text: '',
width: 50,
},
],
height: 50,
},
],
footers: [
{
cells: [
{
textJson:
'{"root":{"children":[{"children":[],"format":"","indent":0,"type":"extended-paragraph","version":1,"textFormat":0}],"format":"","indent":0,"type":"root","version":1}}',
text: '',
width: 50,
},
{
textJson:
'{"root":{"children":[{"children":[],"format":"","indent":0,"type":"extended-paragraph","version":1,"textFormat":0}],"format":"","indent":0,"type":"root","version":1}}',
text: '',
width: 50,
},
],
height: 50,
},
],
},
},
},
},
},
});
```
### JSON Structure Reference
The `textJson` value for each cell follows this structure:
```json
{
"root": {
"children": [
{
"children": [
{
"detail": 0,
"format": 0,
"mode": "normal",
"style": "",
"text": "Your text here",
"type": "extended-text",
"version": 1
}
],
"format": "",
"indent": 0,
"type": "extended-paragraph",
"version": 1,
"textFormat": 0
}
],
"format": "",
"indent": 0,
"type": "root",
"version": 1
}
}
```
To customize the cell text content, replace the `"text"` field value inside the `extended-text` node.
## `rich_text` Property Editor Deprecated
The `rich_text` property editor for custom tools has been deprecated. Use `rich_text_v2` instead. It's a direct replacement with the same HTML string format — no changes needed in your value handling.
**Before:**
```javascript
unlayer.registerTool({
properties: {
content: {
editor: {
data: {
widget: 'rich_text', // Deprecated
},
},
},
},
});
```
**After:**
```javascript
unlayer.registerTool({
properties: {
content: {
editor: {
data: {
widget: 'rich_text_v2', // Use this instead
},
},
},
},
});
```
---
## Authentication(Cli)
The CLI authenticates with **Personal Access Tokens** (PATs). Tokens are scoped to your account and can be revoked any time from the Unlayer Console.
---
## Interactive Login (Recommended)
```bash
unlayer login
```
Here's what happens:
1. Starts a local HTTP server on a random port bound to `127.0.0.1`.
2. Opens your default browser to the Unlayer accounts page (with a `returnUrl` chained through the console's `cli-auth` endpoint).
3. Once you're signed in, the console mints a Personal Access Token and redirects to the local server with the token + a CSRF state parameter.
4. The CLI validates the state, stores the token, and shuts the server down.
The local server binds to `127.0.0.1` only — it's never exposed to the network. The login window times out after 5 minutes.
After the token is verified, you'll be prompted to pick a default workspace and project. The CLI skips that step if you pass `--skip-context`, if there's only one workspace/project to choose from, or if you pass `--workspace-id` and `--project-id` explicitly.
---
## Manual Token Entry
When the browser callback can't reach the CLI's local server (the most common case: you're SSH'd into a remote machine and the browser opens on your laptop), use `--manual`:
```bash
unlayer login --manual
```
This opens the browser to the Personal Access Tokens page (`/home/profile/tokens` in the console) rather than the auto-callback flow. You generate a PAT, copy it, and paste it into the CLI when prompted.
For fully headless environments with no browser at all, skip `login` entirely and use `--token` directly or set `UNLAYER_TOKEN`. See [Non-Interactive Login (CI/CD)](#non-interactive-login-cicd) below.
---
## Non-Interactive Login (CI/CD)
Pass the token directly as a flag or environment variable.
### Via flag
```bash
unlayer login --token unlayer_pat_xxxxxxxxxxxx
```
Tokens must start with `unlayer_pat_`. The CLI validates the prefix and rejects anything else.
### Via environment variable
```bash
export UNLAYER_TOKEN=unlayer_pat_xxxxxxxxxxxx
unlayer template list
```
The CLI checks `UNLAYER_TOKEN` before reading the global config, so an env var always wins.
In CI, you typically set `UNLAYER_TOKEN` as a secret and skip `unlayer login` entirely. Most commands work as long as the token is present in the environment.
---
## All `login` Flags
| Flag | Purpose |
| --------------------- | ----------------------------------------------------- |
| `-t, --token ` | Personal Access Token (skips browser flow) |
| `-u, --url ` | Override API URL (default: `https://api.unlayer.com`) |
| `-m, --manual` | Use manual token entry instead of browser |
| `-f, --force` | Overwrite existing authentication |
| `--workspace-id ` | Auto-select workspace by ID |
| `--project-id ` | Auto-select project by ID |
| `--skip-verify` | Skip token verification (local development only) |
| `--skip-context` | Skip workspace/project selection after login |
| `--json` | Output as JSON |
---
## Verifying Who You Are
```bash
unlayer whoami
```
Calls the API with the active token to confirm it works, then prints a token prefix, the token source (env var or config file), the API URL, the active workspace and project IDs, and the list of workspaces accessible to the token. See [`unlayer whoami`](commands/auth#unlayer-whoami) for the full output.
---
## Logging Out
```bash
unlayer logout
```
Clears the stored token. Pass `-a` / `--all` to clear the entire global config (token, workspace and project preferences, custom API URL, and any environment profiles you defined):
```bash
unlayer logout --all
```
Pass `-f` / `--force` to skip the confirmation prompt.
---
## Token Storage
The CLI uses [`conf`](https://github.com/sindresorhus/conf) to store the token in your OS-standard config location:
| Platform | Path |
| -------- | ------------------------------------------------------ |
| macOS | `~/Library/Preferences/unlayer-cli-nodejs/config.json` |
| Linux | `~/.config/unlayer-cli-nodejs/config.json` |
| Windows | `%APPDATA%\unlayer-cli-nodejs\Config\config.json` |
You can see the exact path with `unlayer config show` (it includes a `Config File` field) or `unlayer config path`.
:::danger Don't commit this file.
It contains your active token. If you ever leak it, revoke the token in the Unlayer Console immediately.
:::
---
## Token Environment Variable Override
By default, the CLI reads the token from `UNLAYER_TOKEN`. You can change the env var name the CLI looks at:
```bash
unlayer config set token-env-var MY_COMPANY_UNLAYER_TOKEN
```
After that, the CLI reads `MY_COMPANY_UNLAYER_TOKEN` instead. Useful if you have multiple Unlayer-related env vars or you're working around naming conflicts in CI.
---
## See Also
- [Configuration](configuration) — full config reference (locations, env profiles)
- [Examples — CI/CD](examples#cicd-token-based) — non-interactive examples
- [Errors](errors) — what to do when the token is invalid or expired
---
## Auth Commands
Three commands handle the authentication lifecycle: `login`, `logout`, `whoami`.
For the conceptual overview of how authentication works (PATs, env vars, token storage), see [Authentication](../authentication).
---
## `unlayer login`
Authenticate with your Unlayer account.
### Syntax
```bash
unlayer login [options]
```
### Description
Starts an OAuth-style browser flow by default: opens the Unlayer accounts page, chains through the console's `cli-auth` endpoint to mint a Personal Access Token, and redirects back to a local server bound to `127.0.0.1`. The token is then stored in your global CLI config.
`--manual` opens the Personal Access Tokens page (`/home/profile/tokens`) instead and prompts you to paste the token. Useful when the browser callback can't reach the CLI — for example, when you're SSH'd into a remote machine.
In CI, pass `--token` or set `UNLAYER_TOKEN` to skip the browser flow entirely.
After authentication, the CLI prompts you to pick a default workspace and project. It skips that step if you pass `--skip-context`, if there's only one workspace/project to choose from, or if you pass `--workspace-id` and `--project-id`. Tokens must start with `unlayer_pat_`; anything else is rejected.
### Options
| Flag | Description |
| --------------------- | --------------------------------------------- |
| `-t, --token ` | Personal Access Token (skip browser flow) |
| `-u, --url ` | API URL (default: `https://api.unlayer.com`) |
| `-m, --manual` | Use manual token entry instead of browser |
| `-f, --force` | Overwrite existing authentication |
| `--workspace-id ` | Auto-select workspace by ID (non-interactive) |
| `--project-id ` | Auto-select project by ID (non-interactive) |
| `--skip-verify` | Skip token verification (local development) |
| `--skip-context` | Skip workspace/project selection after login |
| `--json` | Output as JSON |
### Examples
```bash
# Interactive browser login
unlayer login
# Login with a token (CI-friendly)
unlayer login --token unlayer_pat_xxxxxxxxxxxx
# Auto-select workspace and project (great for scripts)
unlayer login --token $UNLAYER_PAT --workspace-id 42 --project-id 17
# Manual token entry (no browser available)
unlayer login --manual
```
---
## `unlayer logout`
Clear stored credentials.
### Syntax
```bash
unlayer logout [options]
```
### Description
Removes the stored token (and `tokenId`) from the global config. Pass `--all` to clear the entire global config — token, workspace and project preferences, custom API URL, environment profiles, and the active environment.
If you're already not logged in, the command prints `You are not currently logged in.` and exits cleanly without any changes.
### Options
| Flag | Description |
| ------------- | ---------------------------------------------------------------- |
| `-a, --all` | Clear all configuration including workspace and project settings |
| `-f, --force` | Skip confirmation prompt |
| `--json` | Output as JSON |
### Examples
```bash
# Confirm interactively, then clear the token
unlayer logout
# No confirmation (e.g., in scripts)
unlayer logout --force
# Wipe token + workspace + project
unlayer logout --all --force
```
---
## `unlayer whoami`
Verify the active authentication and show context.
### Syntax
```bash
unlayer whoami [options]
```
### Description
Calls the API with the active token to confirm it works, then prints a token prefix, the token source (env var or config file), the API URL, the active workspace and project IDs, and the list of workspaces accessible to the token. Exits with `2` if not authenticated, `1` if the API call fails.
### Options
| Flag | Description |
| -------- | -------------- |
| `--json` | Output as JSON |
### Examples
```bash
unlayer whoami
# Authentication
# Token: unlayer_pat_xxxxxxxx...
# Source: Config file
# API URL: https://api.unlayer.com
#
# Current Context
# Workspace: #42
# Project: #17
#
# Accessible Workspaces
# ├ Acme (ID: 42)
# └ Side Project (ID: 51)
#
# Authentication verified
```
```bash
unlayer whoami --json
# {
# "token": { "prefix": "unlayer_pat_xxxxxxxx...", "isEnvVar": false },
# "apiUrl": "https://api.unlayer.com",
# "workspaceId": 42,
# "projectId": 17,
# "workspaces": [ { "id": 42, "name": "Acme" }, ... ],
# "configPath": "/Users/jane/Library/Preferences/unlayer-cli-nodejs/config.json"
# }
```
A common script pattern — pull the active workspace ID out of the JSON:
```bash
WORKSPACE_ID=$(unlayer whoami --json | jq -r '.workspaceId')
echo "Active workspace: #$WORKSPACE_ID"
```
---
## See Also
- [Authentication](../authentication) — full conceptual guide to PATs and CI
- [Settings commands](settings) — `config show` reveals what's stored
---
## Scaffold a New Project
`unlayer init` creates a new project directory with the email editor pre-wired for your framework. Pick from five official starter templates and the CLI handles install, configuration, and (optionally) connecting the project to your Unlayer account.
## Syntax
```bash
unlayer init [project-name] [options]
```
## Description
`init` creates a new project directory, copies an official starter template, installs dependencies (unless `--no-install`), and optionally connects the project to your Unlayer account.
Run it with no arguments for the interactive flow. The CLI prompts you for a name, framework, and starter template.
## Arguments
| Argument | Description |
| ---------------- | ------------------------------------------------------- |
| `[project-name]` | Name of the project (and the directory it's created in) |
## Options
| Flag | Description |
| ----------------------------- | -------------------------------------------------------------------- |
| `-f, --framework ` | Framework to use (see [supported frameworks](#supported-frameworks)) |
| `-t, --template ` | Starter template (see [starter templates](#starter-templates)) |
| `--no-install` | Skip installing dependencies |
| `--no-typescript` | Disable TypeScript (enabled by default) |
| `--skip-connect` | Skip account connection after scaffolding |
| `--workspace-id ` | Workspace ID to connect (non-interactive) |
| `--project-id ` | Project ID to connect (non-interactive) |
| `-o, --output-dir ` | Directory to create the project in (defaults to cwd) |
| `--json` | Output as JSON |
---
## Supported Frameworks
| Value | Stack |
| ------------ | ---------------------------------- |
| `nextjs` | Next.js 15 (App Router) with React |
| `vite-react` | Vite + React |
| `vite-vue` | Vite + Vue 3 |
| `nuxt` | Nuxt 3 |
| `vanilla` | Plain HTML + JS, no build step |
---
## Starter Templates
| Value | Description |
| ----------- | ---------------------------------- |
| `adventure` | Adventure — travel newsletter |
| `product` | Product — Apple-style announcement |
| `blank` | Blank — start from scratch |
---
## Examples
### Interactive (recommended for first time)
```bash
unlayer init
# → prompts for name, framework, template, account connection
```
### Specify everything inline
```bash
unlayer init my-email-app -f nextjs -t product
```
### Skip dependency install (you'll run it later)
```bash
unlayer init my-app -f vite-react --no-install
cd my-app
pnpm install # or npm/yarn
```
The auto-installer detects your package manager (pnpm → yarn → npm; first one available wins) and runs it inside the new project directory with a 5-minute timeout. Install errors are non-fatal: init reports them and keeps going.
### CI-friendly — fully non-interactive
In non-interactive mode (CI or `--non-interactive`), all three of ``, `--framework`, and `--template` are required. To also auto-connect to your account, supply both `--workspace-id` and `--project-id`.
```bash
unlayer init my-app \
-f nextjs \
-t blank \
--workspace-id 42 \
--project-id 17 \
--json
```
### Plain JS, no TypeScript, no editor connection
```bash
unlayer init landing-page -f vanilla -t blank --no-typescript --skip-connect
```
---
## What `init` Creates
For a Next.js scaffold:
```
my-app/
├── app/
│ ├── editor/
│ │ └── page.tsx # Embedded Unlayer editor
│ ├── layout.tsx
│ └── page.tsx # Landing page
├── public/
│ └── designs/
│ └── sample.json # Starter design (adventure / product / blank)
├── output/
│ └── .gitkeep # Default output directory for `unlayer pull`
├── .gitignore
├── package.json
├── tsconfig.json
├── unlayer.config.json # Project config — see below
└── README.md
```
Other frameworks have their own conventional layouts (Vite + React, Vite + Vue, Nuxt, vanilla HTML). All of them get the same init-managed additions: a starter design at `designs/sample.json` (or `public/designs/sample.json` for non-vanilla), `output/`, `.gitignore`, and `unlayer.config.json`.
### `unlayer.config.json` written by init
```json
{
"$schema": "https://unlayer.com/cli/config-schema.json",
"projectId": null,
"defaults": {
"outputDir": "./output"
}
}
```
`projectId` starts as `null` and is filled in automatically when `init` connects the project to your Unlayer account (interactive prompt or `--workspace-id` + `--project-id` flags). Once set, subsequent CLI commands run inside this directory know which project to operate on.
One thing worth flagging: init's default `outputDir` is `./output` (created with a `.gitkeep`). The CLI's fallback default — used when there's no `unlayer.config.json` — is `./templates`. So `unlayer pull` inside an init-created project lands in `./output/`, but a one-off `unlayer pull` outside a project lands in `./templates/`.
---
## After `init`
```bash
cd my-app
npm run dev # or pnpm/yarn
```
The dev URL depends on the framework. Next.js and Nuxt default to `http://localhost:3000`, Vite to `http://localhost:5173`, and `vanilla` has no dev server (open `index.html` directly). For Next.js the embedded editor is at `/editor`.
If you let `init` connect the project to your Unlayer account (interactive prompt or `--workspace-id` + `--project-id`), `projectId` is set in `unlayer.config.json`. Subsequent CLI commands run inside this directory pick it up:
```bash
unlayer template list # lists templates in this project
unlayer pull # pulls templates into ./output/
```
---
## See Also
- [Configuration — Project Config File](../configuration#project-config-file) — what `unlayer.config.json` does
- [Examples — Bootstrapping a new project](../examples#bootstrapping-a-new-project)
---
## Resource Commands
Three command groups for browsing your Unlayer account:
- `workspace` — list and switch workspaces.
- `project` — list, switch, and inspect projects.
- `template` — list, search, and fetch templates.
All three require authentication ([`unlayer login`](auth#unlayer-login)).
---
## `unlayer workspace`
Manage the active workspace.
### `workspace list`
List all workspaces you have access to. In a TTY (and without `--json`), this is **interactive** — pick a workspace from the list and the CLI offers to set it active and chain into a project picker. In CI or with `--json`, it prints a static `DataTable`.
```bash
unlayer workspace list # interactive picker in a TTY; static table in CI
unlayer workspace list --json # JSON to stdout, no prompts
```
| Flag | Description |
| -------- | -------------- |
| `--json` | Output as JSON |
### `workspace use [workspace-id]`
Set the active workspace. With no argument, prompts interactively.
```bash
unlayer workspace use 42 # set explicitly
unlayer workspace use # interactive picker
```
| Flag | Description |
| -------- | -------------- |
| `--json` | Output as JSON |
The active workspace ID is stored in the global config. It's the default scope for `project list` and similar commands.
---
## `unlayer project`
Manage the active project.
### `project list`
List projects across accessible workspaces. Like `workspace list`, this is **interactive** in a TTY (pick a project to set it active) and a static `DataTable` in CI.
```bash
unlayer project list # interactive picker in a TTY; static table in CI
unlayer project list -w 42 # only workspace 42
unlayer project list --json
```
| Flag | Description |
| ---------------------- | ---------------------- |
| `-w, --workspace ` | Filter by workspace ID |
| `--json` | Output as JSON |
### `project use [project-id]`
Set the active project (interactive picker if you omit the ID).
```bash
unlayer project use 17
unlayer project use # interactive
```
| Flag | Description |
| -------- | -------------- |
| `--json` | Output as JSON |
### `project info [project-id]`
Print detailed information for a project. Defaults to the active project.
```bash
unlayer project info # active project
unlayer project info 17 # specific project
unlayer project info --json
```
| Flag | Description |
| ------------------- | ----------------------------------------- |
| `--project-id ` | Project ID (alias for the positional arg) |
| `--json` | Output as JSON |
Handy in scripts:
```bash
PROJECT_NAME=$(unlayer project info --json | jq -r '.name')
```
---
## `unlayer template`
Browse and fetch templates.
### `template list`
List templates in a project. Defaults to the active project; pass `--project-id` to scope elsewhere. In a TTY this is interactive — after the list loads, pick a template to open an action menu (view, get JSON, etc.). In CI it prints a static `DataTable`.
```bash
unlayer template list # interactive picker in a TTY; static table in CI
unlayer template list --limit 20
unlayer template list -s welcome
unlayer template list --json
```
| Flag | Description |
| ---------------------- | ------------------------------------- |
| `--project-id ` | Filter templates by project ID |
| `--limit ` | Maximum number of templates to return |
| `-s, --search ` | Search templates by name |
| `--json` | Output as JSON |
### `template search `
Search templates by name, case-insensitive. Same interactive-vs-static behavior as `template list`.
```bash
unlayer template search welcome # interactive picker in a TTY
unlayer template search "black friday" --limit 10 --json
```
| Flag | Description |
| ------------------- | ------------------------- |
| `--project-id ` | Filter by project ID |
| `--limit ` | Maximum number of results |
| `--json` | Output as JSON |
### `template get [template-id]`
Fetch a single template. Without an ID in a TTY, opens an interactive picker. In non-interactive mode without an ID, fails with exit `2` and lists available IDs.
By default (no `--json`, no `--design-only`, no `-o`), prints a key/value summary — name, ID, created/updated dates — plus a hint pointing at the flags below. To actually export the data, pick one of:
- `--json` → full envelope to stdout as JSON.
- `--design-only` → just `template.design` to stdout as JSON.
- `-o ` → write envelope (or design with `--design-only`) to a file.
- `-o -` → write to stdout.
```bash
# Interactive summary
unlayer template get 12345
# Print full envelope to stdout
unlayer template get 12345 --json
# Save envelope to file
unlayer template get 12345 -o ./welcome.json
# Just the design JSON, to file
unlayer template get 12345 --design-only -o ./welcome.design.json
# Pipe stdout (use - as the output target)
unlayer template get 12345 -o - | jq '.design.body.rows | length'
```
| Flag | Description |
| --------------------- | ------------------------------------------ |
| `-o, --output ` | Save output to file (`-` writes to stdout) |
| `--design-only` | Output only the design JSON |
| `--project-id ` | Project ID to scope the lookup |
| `--json` | Output as JSON |
To pull every template in a project at once, use [`unlayer pull`](sync#unlayer-pull) instead of looping over `template get`.
---
## See Also
- [Sync commands](sync) — `pull` and `diff` for bulk template work
- [Configuration → Resolution Order](../configuration#resolution-order) — how the active workspace/project is resolved
---
## Settings Commands
Three command groups for managing the CLI itself:
- `config` — read and write the global config.
- `env` — manage environment profiles (self-hosted deployments, etc).
- `status` — health check.
For the conceptual model — where the global config lives, what an environment profile is — see [Configuration](../configuration).
---
## `unlayer config`
Read and modify the global CLI config.
### `config show`
Print the current configuration.
```bash
unlayer config show
unlayer config show --json
```
| Flag | Description |
| -------- | -------------- |
| `--json` | Output as JSON |
The output includes the path to the config file on disk, which is useful when you need to back it up or copy it to another machine.
### `config set `
Set a single configuration key. Two keys are accepted:
| Key | Effect |
| --------------- | ---------------------------------------------------------------------------- |
| `api-url` | Override the API base URL |
| `token-env-var` | Change which env var the CLI reads the token from (default: `UNLAYER_TOKEN`) |
```bash
unlayer config set api-url https://unlayer-api.your-domain.com
unlayer config set token-env-var MY_COMPANY_UNLAYER_TOKEN
```
| Flag | Description |
| -------- | -------------- |
| `--json` | Output as JSON |
To set the **active workspace or project**, use `unlayer workspace use ` or `unlayer project use ` instead. Those are stored differently from the keys above.
### `config path`
Print the path to the global config file. Handy in scripts.
```bash
unlayer config path
# /Users/jane/Library/Preferences/unlayer-cli-nodejs/config.json
unlayer config path --json
# {"path":"/Users/jane/Library/Preferences/unlayer-cli-nodejs/config.json"}
```
| Flag | Description |
| -------- | -------------- |
| `--json` | Output as JSON |
### `config reset`
Reset the configuration to defaults. Clears the token, workspace, project, environments — everything.
```bash
unlayer config reset # prompts for confirmation
unlayer config reset --force # no prompt
```
| Flag | Description |
| ------------- | ------------------------ |
| `-f, --force` | Skip confirmation prompt |
| `--json` | Output as JSON |
---
## `unlayer env`
Manage **environment profiles** — named sets of API/Console/Accounts URLs you can switch between.
Useful when you point the CLI at a self-hosted Unlayer deployment without rewriting the global config every time.
### `env list`
List all environment profiles.
```bash
unlayer env list
unlayer env list --json
```
| Flag | Description |
| -------- | -------------- |
| `--json` | Output as JSON |
The output flags which profile is active and where each one is defined (global config or the project's `unlayer.config.json`).
### `env add `
Add or update a named environment profile in your global config. All three URLs are **required flags** — there is no interactive prompt.
```bash
unlayer env add self-hosted \
--api-url https://unlayer-api.your-domain.com \
--console-url https://unlayer-console.your-domain.com \
--accounts-url https://unlayer-accounts.your-domain.com
```
| Argument | Description |
| -------- | ----------------------------------------------------------------- |
| `` | Profile name (any string you choose, e.g. `self-hosted`, `local`) |
| Flag | Description |
| ---------------------- | ------------------------------- |
| `--api-url ` | **Required.** API base URL |
| `--console-url ` | **Required.** Console base URL |
| `--accounts-url ` | **Required.** Accounts base URL |
| `--json` | Output as JSON |
All three URLs are validated as parsable URLs at runtime. Invalid input fails with exit code `2`.
### `env use [name]`
Switch the active environment profile.
```bash
unlayer env use self-hosted # activate the named profile
unlayer env use --none # back to default URLs
unlayer env use # interactive picker
```
| Argument | Description |
| -------- | ------------------------------------------ |
| `[name]` | Profile name to activate (omit for picker) |
| Flag | Description |
| -------- | --------------------------------------- |
| `--none` | Clear active profile (back to defaults) |
| `--json` | Output as JSON |
### `env show [name]`
Show the URLs configured for a profile.
```bash
unlayer env show self-hosted # specific profile
unlayer env show # the active profile
```
| Argument | Description |
| -------- | --------------------------------- |
| `[name]` | Profile name (defaults to active) |
| Flag | Description |
| -------- | -------------- |
| `--json` | Output as JSON |
### `env remove `
Remove a profile from the global config. If you remove the active profile, the CLI clears the active selection.
```bash
unlayer env remove self-hosted
```
| Argument | Description |
| -------- | ---------------------- |
| `` | Profile name to remove |
| Flag | Description |
| -------- | -------------- |
| `--json` | Output as JSON |
You can also define profiles in `unlayer.config.json` to share them with your team via git. `env add` writes to the global (per-machine) config, and those overrides win on name conflicts with project-defined profiles.
---
## `unlayer status`
Check CLI health and connectivity.
### Syntax
```bash
unlayer status [options]
```
### Description
Reports authentication state, API reachability and latency, the active workspace and project IDs, the API URL, and the path to the global config file. Doesn't require authentication, which makes it useful as the first command to run when something is off. **Exits with code `1` when not authenticated or when the API is unreachable**, otherwise `0`.
### Options
| Flag | Description |
| -------- | -------------- |
| `--json` | Output as JSON |
### Examples
```bash
unlayer status
# Authenticated: Yes
# API Reachable: Yes
# API Latency: 42ms
# API URL: https://api.unlayer.com
# Workspace ID: 42
# Project ID: 17
# Config File: /Users/jane/Library/Preferences/unlayer-cli-nodejs/config.json
# ✓ All systems operational
```
```bash
unlayer status --json
# {
# "authenticated": true,
# "apiReachable": true,
# "apiLatencyMs": 42,
# "apiUrl": "https://api.unlayer.com",
# "workspaceId": 42,
# "projectId": 17,
# "configPath": "/Users/jane/Library/Preferences/unlayer-cli-nodejs/config.json",
# "error": null
# }
# In CI, gate on JSON output
HEALTH=$(unlayer status --json)
echo "$HEALTH" | jq -e '.authenticated and .apiReachable'
```
---
## See Also
- [Configuration](../configuration) — full conceptual reference for the global config and project config file
- [Authentication → Token Environment Variable Override](../authentication#token-environment-variable-override) — why you'd set `tokenEnvVar`
---
## Sync Commands
Two commands for keeping templates in sync between Unlayer and your local files:
- `pull` — download templates as JSON files.
- `diff` — compare a local design against its remote version.
These are the workhorse commands for git-based template workflows.
---
## `unlayer pull`
Download templates from a project to local files.
### Syntax
```bash
unlayer pull [options]
```
### Description
Pulls all templates in the active project (or one template with `--id`) and writes them as JSON files to the output directory. Each file is named `{slugified-name}-{id}.json` (e.g. `welcome-email-42.json`).
The output directory defaults to `./templates`. Override with `--output-dir`, or set `defaults.outputDir` in `unlayer.config.json`.
### Options
| Flag | Description |
| -------------------- | ------------------------------------------------- |
| `--id ` | Pull a single template by ID |
| `--output-dir ` | Output directory (default: `./templates`) |
| `--project-id ` | Project ID to scope the pull |
| `--design-only` | Save only the design JSON (not the full envelope) |
| `--json` | Output as JSON |
### Output Format
Without `--design-only`, each file contains the template envelope:
```json
{
"id": 42,
"name": "Welcome Email",
"displayMode": "email",
"design": {
/* design JSON */
},
"createdAt": "2026-04-01T12:00:00.000Z",
"updatedAt": "2026-05-05T09:30:00.000Z"
}
```
With `--design-only`, files contain just the design JSON — the same shape returned by the visual builder's `saveDesign` callback. That's the form you typically commit to git when you also want to load it via `editor.loadDesign(...)`.
### Examples
```bash
# Pull all templates in the active project
unlayer pull
# Pull a single template
unlayer pull --id 42
# Pull design JSON only (cleaner for git)
unlayer pull --design-only
# Pull from a specific project, custom output dir
unlayer pull --project-id 17 --output-dir ./email-designs
# In CI — JSON output for parsing
unlayer pull --json | jq 'length' # output is an array of {id, name, path}
```
---
## `unlayer diff `
Compare a local design file against the remote template it came from.
### Syntax
```bash
unlayer diff [options]
```
### Description
Reads a local design JSON file, looks up the matching remote template (auto-detected from the file's metadata, or explicit via `--remote`), and prints the differences.
The diff is human-readable by default. Pass `--json` for a structured diff suitable for scripting — typically a CI gate that fails when the local file has drifted from the remote source of truth.
### Arguments
| Argument | Description |
| -------- | -------------------------------- |
| `` | Path to a local design JSON file |
### Options
| Flag | Description |
| ------------------- | ------------------------------------------------------- |
| `--remote ` | Remote template ID (auto-detected from file if omitted) |
| `--project-id ` | Project ID to scope the lookup |
| `--json` | Output diff as structured JSON |
### Examples
```bash
# Compare a local file against its remote counterpart
unlayer diff ./templates/welcome-email-42.json
# Force a specific remote ID (when the file's metadata is stale)
unlayer diff ./welcome.json --remote 42
# Use in CI to fail when a checked-in template is out of sync
if ! unlayer diff ./templates/welcome.json --json | jq -e '.totalChanges == 0'; then
echo "Template has drifted from remote"
exit 1
fi
```
---
## Common Patterns
### Git-based template workflow
Treat the templates dir as part of your repo, pull regularly, commit:
```bash
unlayer pull --design-only
git add templates
git commit -m "Sync templates from Unlayer"
```
### Keep templates in sync with a CI gate
Run on every PR to catch drift between local files and the remote source of truth:
```bash
unlayer pull --design-only --output-dir ./templates.remote
diff -r ./templates ./templates.remote || exit 1
```
See [Examples](../examples) for full end-to-end workflows.
---
## See Also
- [Resource commands → template](resources#unlayer-template) — fetch single templates
- [Examples](../examples) — git-based template workflow
- [Configuration](../configuration) — set `defaults.outputDir` in `unlayer.config.json`
---
## Configuration
The CLI reads configuration from three sources, in order of precedence:
1. **Environment variables** — for CI and one-off overrides.
2. **Global config** — your stored CLI state (`unlayer login`, `unlayer config set`, `unlayer workspace use`, etc.).
3. **Project config** — `unlayer.config.json` in your repo.
A typical setup uses the global config for personal authentication and the project config for project-scoped defaults like the project ID and output directory.
---
## Global Flags
These work on every command:
| Flag | Effect |
| ------------------- | ---------------------------------------------------- |
| `--json` | Output as machine-readable JSON (stdout) |
| `--non-interactive` | Never prompt; fail if input would be required |
| `-v, --verbose` | Verbose logging to stderr |
| `-q, --quiet` | Suppress decorative output (header, status messages) |
CI environments automatically enable `--non-interactive` and `--quiet`. The CLI detects them via the `CI` env var, `CONTINUOUS_INTEGRATION`, `UNLAYER_NON_INTERACTIVE`, or a non-TTY stdin.
### Output Contract
The CLI separates streams cleanly so you can pipe and parse:
- **stdout** — machine-readable data (JSON, URLs). Safe to `| jq` or capture.
- **stderr** — human messages, spinners, errors.
- In `--json` mode, errors are written to stderr as `{"error": "..."}`.
---
## Environment Variables
| Variable | Effect |
| ------------------------- | ----------------------------------------------------- |
| `UNLAYER_TOKEN` | Personal Access Token (highest-priority token source) |
| `UNLAYER_API_URL` | Override the API base URL |
| `UNLAYER_WORKSPACE_ID` | Override the active workspace |
| `UNLAYER_PROJECT_ID` | Override the active project |
| `UNLAYER_NON_INTERACTIVE` | Force non-interactive mode |
| `CI` | Auto-enables non-interactive + quiet |
| `CONTINUOUS_INTEGRATION` | Same as `CI` |
The token env var name itself is configurable. See [Authentication → Token Environment Variable Override](authentication#token-environment-variable-override).
---
## Global Config File
Stored by [`conf`](https://github.com/sindresorhus/conf) in your OS-standard config location:
| Platform | Path |
| -------- | ------------------------------------------------------ |
| macOS | `~/Library/Preferences/unlayer-cli-nodejs/config.json` |
| Linux | `~/.config/unlayer-cli-nodejs/config.json` |
| Windows | `%APPDATA%\unlayer-cli-nodejs\Config\config.json` |
Run `unlayer config show` to see the exact path on your machine.
### Stored Keys
| Key | Set By | Purpose |
| ------------------- | ---------------------------------------------------- | -------------------------------------------------------------- |
| `token` | `unlayer login` | Active Personal Access Token |
| `tokenId` | `unlayer login` | Server-side ID of the token (for revocation) |
| `tokenEnvVar` | `unlayer config set token-env-var` | Name of the env var the CLI looks at (default `UNLAYER_TOKEN`) |
| `apiUrl` | `unlayer config set api-url` / `unlayer login --url` | API base URL |
| `workspaceId` | `unlayer workspace use` | Active workspace |
| `projectId` | `unlayer project use` | Active project |
| `environments` | `unlayer env add` | Named environment profiles |
| `activeEnvironment` | `unlayer env use` | Currently selected environment profile |
You normally manage these via commands rather than editing the file directly.
---
## Project Config File
Drop a `unlayer.config.json` at the root of your repo to commit project-scoped defaults:
```json
{
"projectId": 17,
"defaults": {
"outputDir": "./templates"
},
"environments": {
"self-hosted": {
"apiUrl": "https://unlayer-api.your-domain.com",
"consoleUrl": "https://unlayer-console.your-domain.com",
"accountsUrl": "https://unlayer-accounts.your-domain.com"
}
}
}
```
The CLI walks up from the current directory to find this file, the way `.gitignore` resolution works.
| Key | Type | Purpose |
| -------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
| `projectId` | `number \| null` | Default project for commands that need one |
| `defaults.outputDir` | `string` | Default output directory for `unlayer pull` (defaults to `./templates`) |
| `environments` | `Record` | Named environment profiles for this project |
> 🔒 **Security:** the keys `token`, `apiKey`, and `$schema` are stripped from the parsed config so a leaked file can't leak credentials. Don't put secrets in this file. Use env vars instead.
---
## Environment Profiles
If you need to point the CLI at a different Unlayer deployment (a self-hosted instance, for example), save the URLs as a named profile and switch with one command:
```bash
# Define a profile (saved in global config)
unlayer env add self-hosted
# Switch the active profile
unlayer env use self-hosted
# Inspect
unlayer env list
unlayer env show self-hosted
# Switch back to defaults
unlayer env use --none
```
Profiles set the API URL, Console URL, and Accounts URL together. They can be defined in the global config (personal) or in `unlayer.config.json` (project-shared). Global wins on name conflicts. See [Settings commands → env](commands/settings#unlayer-env) for details.
---
## Resolution Order
For each setting, the CLI uses the first source that has a value:
| Setting | 1st | 2nd | 3rd | 4th |
| ------------ | --------------------------------------------------------- | ------------------------------------------ | --------------------------------- | ------------------------- |
| Token | `UNLAYER_TOKEN` env var (or whatever `tokenEnvVar` names) | global config `token` | — | — |
| API URL | `UNLAYER_API_URL` | active env profile | global config `apiUrl` | `https://api.unlayer.com` |
| Workspace ID | `UNLAYER_WORKSPACE_ID` | global config `workspaceId` | — | — |
| Project ID | `UNLAYER_PROJECT_ID` | global config `projectId` | `unlayer.config.json` `projectId` | — |
| Output dir | command-line `--output-dir` | `unlayer.config.json` `defaults.outputDir` | `./templates` | — |
---
## See Also
- [Authentication](authentication) — token storage, the `tokenEnvVar` knob
- [Settings commands](commands/settings) — `config`, `env`, `status`
- [Examples — pointing at a self-hosted deployment](examples#pointing-at-a-self-hosted-deployment)
---
## Errors & Troubleshooting
The CLI uses three exit codes and writes errors to stderr. In `--json` mode, errors are JSON-encoded so they're parseable.
---
## Exit Codes
| Code | Meaning |
| ---- | ------------------------------------------------------------------------------ |
| `0` | Success |
| `1` | Runtime error — API failure, network problem, unexpected exception |
| `2` | Usage error — bad input, missing required arg, unknown flag, not authenticated |
Use these in shell scripts:
```bash
if ! unlayer pull; then
case $? in
1) echo "API or runtime error — retry?" ;;
2) echo "Bad usage or not authenticated — fix invocation" ;;
esac
fi
```
---
## Output Streams
| Stream | Content |
| ------ | ------------------------------------------------------ |
| stdout | Machine-readable data only (JSON, URLs). Safe to pipe. |
| stderr | Human-readable messages, spinners, errors |
In `--json` mode, errors land on stderr in the form `{"error": "..."}`.
---
## Common Errors
### `Not authenticated`
```
✗ Not authenticated. Run unlayer login to get started.
```
The CLI couldn't find a token. Fix:
```bash
unlayer login
# or in CI:
export UNLAYER_TOKEN=unlayer_pat_xxxxxxxx
```
If you've configured a non-default token env var name (via `config set token-env-var ...`), the CLI looks at _that_ variable, not `UNLAYER_TOKEN`. Check with:
```bash
unlayer config show
# → look for the "Token Env Var" line
```
### `Invalid token` / `401 Unauthorized`
The stored token has been revoked or expired. Re-authenticate:
```bash
unlayer logout
unlayer login
```
### `Project not found` / `Project ID required`
Some commands need a project ID. Resolution order:
1. `--project-id` flag on the command
2. `UNLAYER_PROJECT_ID` env var
3. `unlayer project use ` (writes to global config)
4. `projectId` in `unlayer.config.json` at the repo root
If none of these is set, commands that scope to a project will fail. Run:
```bash
unlayer project list
unlayer project use
```
### `Workspace not found` / `Workspace ID required`
Same pattern as Project ID, but for workspace scope. Resolved in this order:
1. `UNLAYER_WORKSPACE_ID` env var
2. `unlayer workspace use `
```bash
unlayer workspace list
unlayer workspace use
```
### `unknown option '--xxx'` / `missing required argument`
These are Commander parse errors. The CLI exits with code `2`. The error message names the offending command and option. Run `unlayer --help` to see valid options.
```bash
unlayer pull --help
```
### `unlayer.config.json` parse warning
```
Warning: Failed to parse /path/to/unlayer.config.json: ...
```
The CLI found a config file but it isn't valid JSON. The CLI keeps going with defaults. Validate the file with `cat unlayer.config.json | jq .`.
### Authentication browser flow times out
```
Authentication timed out after 5 minutes. Please try again.
```
The CLI listened for the OAuth callback for 5 minutes and gave up. Common causes:
- The browser couldn't reach `localhost`.
- A firewall blocked the local server (it binds to `127.0.0.1`).
- You closed the browser tab before completing sign-in.
Try again, or use manual token entry:
```bash
unlayer login --manual
```
### `EACCES` / `permission denied` during install
```bash
npm install -g @unlayer/cli
# → EACCES: permission denied
```
Use a Node version manager (nvm, fnm, volta) instead of system Node, or change npm's global prefix to a user-writable location. Don't `sudo npm install -g`.
### `Node version not supported`
The CLI requires **Node 20+**. Check with `node --version`. If you're below 20:
```bash
nvm install 20
nvm use 20
```
---
## Verbose Logging
When you can't tell why a command is failing, run with `--verbose`:
```bash
unlayer pull --verbose
```
Verbose output goes to stderr (so it doesn't break `--json` piping) and shows config resolution, API URLs, and HTTP status codes.
---
## Connectivity Check
`unlayer status` is the quickest way to verify the CLI can reach the API:
```bash
unlayer status
```
It works without authentication, which makes it useful when your token has expired or you're debugging a corporate proxy.
---
## Where to Report Bugs
Open an issue at [github.com/unlayer/cli/issues](https://github.com/unlayer/cli/issues) with:
- The command you ran
- The output (use `--verbose` to capture more)
- `unlayer status --json` output
- Your OS and Node version
---
## See Also
- [Authentication](authentication) — token-related errors
- [Configuration](configuration) — config-related errors
- [Settings → status](commands/settings#unlayer-status) — health-check command
---
## Examples(Cli)
Realistic end-to-end workflows for the CLI.
---
## Bootstrapping a new project
You want a working email editor in your app, with a starter design and the project pre-connected to your Unlayer account.
```bash
# 1. Sign in
unlayer login
# 2. Scaffold (Next.js + product starter)
unlayer init my-emails -f nextjs -t product
# 3. Run it
cd my-emails
npm run dev
# → http://localhost:3000/editor
```
The scaffold writes `unlayer.config.json` with your `projectId`, so any future CLI command run from inside the project automatically scopes to it:
```bash
unlayer template list # lists templates in *this* project
unlayer pull # pulls them into ./output (the outputDir written by `unlayer init`)
```
---
## Git-based template workflow
You want templates checked into your repo so design changes are visible in PRs.
### One-time setup
```bash
cd your-app
unlayer login
unlayer project use 17 # pick the project
unlayer pull --design-only # writes designs into ./templates
git add templates
git commit -m "Initial template snapshot"
```
### Daily flow
```bash
# Designer makes changes in the visual editor → save in Unlayer
unlayer pull --design-only
git diff # → see exactly what changed
git commit -m "Sync templates"
```
### CI gate (catch drift)
Add to your CI:
```bash
unlayer pull --design-only --output-dir ./templates.remote
if ! diff -r ./templates ./templates.remote >/dev/null; then
echo "::error::Templates in repo are out of sync with Unlayer"
exit 1
fi
```
Or use `unlayer diff` per file. The JSON result has a `totalChanges` field; zero means no drift:
```bash
for f in templates/*.json; do
if ! unlayer diff "$f" --json | jq -e '.totalChanges == 0' >/dev/null; then
echo "Drifted: $f"
exit 1
fi
done
```
---
## CI/CD (token-based)
Most CI workflows skip `unlayer login` entirely and pass the token via env var instead:
```yaml
# .github/workflows/sync-templates.yml
name: Sync Unlayer Templates
on:
schedule:
- cron: '0 6 * * *' # daily 06:00 UTC
workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 24 }
- run: npm install -g @unlayer/cli
- run: unlayer pull --design-only --json
env:
UNLAYER_TOKEN: ${{ secrets.UNLAYER_PAT }}
UNLAYER_PROJECT_ID: '17'
- uses: peter-evans/create-pull-request@v6
with:
title: 'chore: sync Unlayer templates'
commit-message: 'chore: sync Unlayer templates'
branch: unlayer-template-sync
```
The CLI auto-enables `--non-interactive` and `--quiet` when it sees the `CI` env var. Errors go to stderr; `--json` keeps stdout machine-parseable.
---
## Pointing at a self-hosted deployment
Your team runs a self-hosted Unlayer deployment alongside the default `api.unlayer.com`. Save the alternate URLs as a named profile and switch with one command.
### Define the profile
```bash
unlayer env add self-hosted \
--api-url https://unlayer-api.your-domain.com \
--console-url https://unlayer-console.your-domain.com \
--accounts-url https://unlayer-accounts.your-domain.com
```
### Switch between deployments
```bash
unlayer env use self-hosted
unlayer template list # → reads from your self-hosted instance
unlayer pull --design-only
unlayer env use --none # back to the default URLs
unlayer template list # → reads from api.unlayer.com
```
### Share profiles via git
Drop a `unlayer.config.json` at the repo root so the whole team can `unlayer env use self-hosted` without configuring URLs locally:
```json
{
"projectId": 17,
"environments": {
"self-hosted": {
"apiUrl": "https://unlayer-api.your-domain.com",
"consoleUrl": "https://unlayer-console.your-domain.com",
"accountsUrl": "https://unlayer-accounts.your-domain.com"
}
}
}
```
---
## Inspect a single template programmatically
```bash
TEMPLATE_ID=42
# Pull just the design JSON to stdout
unlayer template get "$TEMPLATE_ID" --design-only -o -
# Save it
unlayer template get "$TEMPLATE_ID" --design-only -o ./welcome.json
# Count rows in the design
unlayer template get "$TEMPLATE_ID" --design-only -o - \
| jq '.body.rows | length'
```
---
## Troubleshooting in one command
When something is off, run `status` first:
```bash
unlayer status
```
It prints authentication state, API reachability and latency, the active workspace/project IDs, and your config file path. It doesn't require auth, so it works even when your token has expired. Exits with code `1` when something is wrong, otherwise `0`.
If `Authenticated: No`, run `unlayer login` again.
---
## See Also
- [Authentication](authentication) — token setup
- [Configuration](configuration) — env profiles, project config
- [Errors](errors) — exit codes and what to do when commands fail
---
## Installation(Cli)
The CLI ships as [`@unlayer/cli`](https://www.npmjs.com/package/@unlayer/cli) on npm. The binary is `unlayer`.
---
## Requirements
| Requirement | Version |
| ----------- | ------- |
| Node.js | `>= 20` |
The CLI is published as ESM. Older Node versions aren't supported.
---
## Install Globally
```bash
npm install -g @unlayer/cli
# or
pnpm add -g @unlayer/cli
# or
yarn global add @unlayer/cli
```
After install, the `unlayer` binary is on your `PATH`:
```bash
unlayer --version
# 0.1.0
```
---
## Run Without Installing
For a one-off invocation, use `npx`:
```bash
npx @unlayer/cli login
npx @unlayer/cli template list
```
This pulls the latest published version each time.
---
## Verify the Install
```bash
unlayer --version
# 0.1.0
unlayer status
```
`unlayer status` reports authentication state, API reachability and latency, the active workspace and project IDs, and the path to your config file. It works without authentication, which makes it useful as the first command to run when something is off.
---
## Update Notifications
The CLI checks for a newer version in the background once per day and prints a notification on exit when an update is available:
```
Update available 0.1.0 → 0.2.0
Run npm i -g @unlayer/cli to update
```
---
## What's Next
- [Authentication](authentication) — sign in with a Personal Access Token
- [Quick Start](/cli) — first command in 30 seconds
---
## CLI
`@unlayer/cli` lets you manage your Unlayer account from the terminal: list and pull templates, switch workspaces and projects, scaffold new apps from official starter templates, and run automation in CI.
It's `unlayer` on the command line.
```bash
unlayer login
unlayer template list
unlayer pull
unlayer diff ./templates/welcome-email-42.json
```
:::tip Looking for the programmatic API?
For calling the Unlayer Cloud API from code (Node, serverless, server-side), see the **[SDK](/sdk)** — same account, different surface.
:::
---
## What you can do
| Group | Commands | Use For |
| ------------- | ------------------------------------ | ------------------------------------------------------------------------- |
| **Auth** | `login` · `logout` · `whoami` | Sign in with a Personal Access Token |
| **Resources** | `workspace` · `project` · `template` | Browse, select, and inspect your account |
| **Sync** | `pull` · `diff` | Pull templates to local files, compare local vs remote |
| **Init** | `init` | Scaffold a new project (Next.js, Vite + React, Vite + Vue, Nuxt, vanilla) |
| **Settings** | `config` · `env` · `status` | Manage CLI configuration, environment profiles, health |
Every command supports `--json` for machine-readable output and `--non-interactive` for CI usage. See [Authentication](/cli/authentication) for token setup.
---
## Quick Start
```bash
# 1. Install
npm install -g @unlayer/cli
# 2. Authenticate (opens browser)
unlayer login
# 3. Scaffold a project
unlayer init my-app
# 4. Or pull templates from an existing project
unlayer pull
```
Walkthrough: [Installation](/cli/installation) → [Authentication](/cli/authentication) → [Examples](/cli/examples).
---
## Documentation Map
**Getting started**
- [Installation](/cli/installation) — install via npm, Node version, verify
- [Authentication](/cli/authentication) — Personal Access Tokens, browser login, CI usage
- [Configuration](/cli/configuration) — project config file, environment profiles, global flags
**Command reference**
- [Auth commands](/cli/commands/auth) — `login`, `logout`, `whoami`
- [Resource commands](/cli/commands/resources) — `workspace`, `project`, `template`
- [Sync commands](/cli/commands/sync) — `pull`, `diff`
- [Init command](/cli/commands/init) — `init` (with frameworks and starter templates)
- [Settings commands](/cli/commands/settings) — `config`, `env`, `status`
**Reference**
- [Examples](/cli/examples) — end-to-end workflows (CI/CD, template sync, multi-environment)
- [Errors](/cli/errors) — exit codes and common error messages
- [Quick Reference](/cli/quick-reference) — single-page cheat sheet
---
## Requirements
- **Node.js 20 or later**
- An Unlayer account (free signup at [accounts.unlayer.com](https://accounts.unlayer.com/signup/console))
---
## Quick Reference
Every command, flag, env var, and exit code on one page.
---
## Install
```bash
npm install -g @unlayer/cli # requires Node 20+
unlayer --version # → 0.1.0
```
---
## Auth
| Command | Description |
| --------------------------- | --------------------------------------- |
| `unlayer login` | Authenticate (browser flow) |
| `unlayer login --token ` | Non-interactive login |
| `unlayer login --manual` | Manual token entry |
| `unlayer logout` | Clear token |
| `unlayer logout --all` | Clear token + workspace + project |
| `unlayer whoami` | Show current user / workspace / project |
**`login` flags:** `-t/--token`, `-u/--url`, `-m/--manual`, `-f/--force`, `--workspace-id`, `--project-id`, `--skip-verify`, `--skip-context`, `--json`
**`logout` flags:** `-a/--all`, `-f/--force`, `--json`
---
## Resources
```bash
# Workspaces
unlayer workspace list
unlayer workspace use [id]
# Projects
unlayer project list [-w ]
unlayer project use [id]
unlayer project info [id]
# Templates
unlayer template list [--limit n] [-s ] [--project-id ]
unlayer template search [--limit n] [--project-id ]
unlayer template get [id] [-o ] [--design-only] [--project-id ]
```
---
## Sync
```bash
unlayer pull [--id ] [--output-dir ] [--project-id ] [--design-only]
unlayer diff [--remote ] [--project-id ]
```
---
## Init
```bash
unlayer init [name] [-f ] [-t ] [options]
```
| `-f, --framework` | `nextjs` · `vite-react` · `vite-vue` · `nuxt` · `vanilla` |
| ----------------- | --------------------------------------------------------- |
| `-t, --template` | `adventure` · `product` · `blank` |
Other flags: `--no-install`, `--no-typescript`, `--skip-connect`, `--workspace-id`, `--project-id`, `-o/--output-dir`, `--json`
---
## Settings
```bash
# Global config
unlayer config show
unlayer config path # print config file path
unlayer config set # keys: api-url, token-env-var
unlayer config reset [-f]
# Environment profiles
unlayer env list
unlayer env add --api-url --console-url --accounts-url
unlayer env use [name] [--none]
unlayer env show [name]
unlayer env remove
# Health check (exit 1 when unauthenticated or unreachable)
unlayer status
```
---
## Global Flags
| Flag | Effect |
| ------------------- | -------------------------------- |
| `--json` | Machine-readable JSON to stdout |
| `--non-interactive` | Never prompt; auto-enabled in CI |
| `-v, --verbose` | Verbose logging to stderr |
| `-q, --quiet` | Suppress decorative output |
CI mode auto-enabled when any of `CI`, `CONTINUOUS_INTEGRATION`, or `UNLAYER_NON_INTERACTIVE` is set, or when stdin is not a TTY.
---
## Environment Variables
| Variable | Effect |
| ------------------------------- | ------------------------------------ |
| `UNLAYER_TOKEN` | Personal Access Token |
| `UNLAYER_API_URL` | Override API base URL |
| `UNLAYER_WORKSPACE_ID` | Override active workspace |
| `UNLAYER_PROJECT_ID` | Override active project |
| `UNLAYER_NON_INTERACTIVE` | Force non-interactive mode |
| `CI` / `CONTINUOUS_INTEGRATION` | Auto-enables non-interactive + quiet |
The token env var name itself is configurable: `unlayer config set token-env-var MY_VAR`.
---
## Exit Codes
| Code | Meaning |
| ---- | ------------------------------------------------------ |
| `0` | Success |
| `1` | Runtime error (API, network, unexpected) |
| `2` | Usage error (bad flag, not authenticated, missing arg) |
---
## Resolution Order
| Setting | 1st | 2nd | 3rd | 4th |
| ------------ | ---------------------- | ------------------------------------------ | ---------------------- | ------------------------- |
| Token | env var | global config | — | — |
| API URL | `UNLAYER_API_URL` | active env profile | global config `apiUrl` | `https://api.unlayer.com` |
| Workspace ID | `UNLAYER_WORKSPACE_ID` | global config | — | — |
| Project ID | `UNLAYER_PROJECT_ID` | global config | `unlayer.config.json` | — |
| Output dir | `--output-dir` | `unlayer.config.json` `defaults.outputDir` | `./templates` | — |
---
## `unlayer.config.json`
Drop at the repo root to commit project-scoped defaults:
```json
{
"projectId": 17,
"defaults": {
"outputDir": "./templates"
},
"environments": {
"self-hosted": {
"apiUrl": "https://unlayer-api.your-domain.com",
"consoleUrl": "https://unlayer-console.your-domain.com",
"accountsUrl": "https://unlayer-accounts.your-domain.com"
}
}
}
```
`token`, `apiKey`, and `$schema` are stripped on read. Don't put secrets here.
---
## Common Workflows (one-liners)
```bash
# First-time setup
unlayer login && unlayer init my-app -f nextjs
# Pull templates and commit them
unlayer pull --design-only && git add templates && git commit -m "sync"
# CI: token via env, JSON output
UNLAYER_TOKEN=$PAT UNLAYER_PROJECT_ID=17 unlayer pull --json
# Drift gate
for f in templates/*.json; do
unlayer diff "$f" --json | jq -e '.identical' || exit 1
done
# Switch between deployments
unlayer env use self-hosted && unlayer pull
unlayer env use --none && unlayer pull # back to api.unlayer.com
# When something is broken
unlayer status # health check (no auth needed); exits 1 when unhealthy
unlayer --verbose # detailed logs
```
---
## See Also
- [Overview](/cli)
- [Authentication](authentication)
- [Configuration](configuration)
- [Examples](examples)
- [Errors](errors)
---
## Elements
**Unlayer Elements** is a **free, open-source** code-first component library for building emails, pages, and documents. It uses the same design surface as the visual builder, expressed as JSX.
---
## Introduction
Watch the short walkthrough below for a quick tour of Unlayer Elements.
---
## How it works
Write once in React. Render anywhere — email, web or PDF.

---
## Available packages
Published to npm:
| npm package | Framework | Requirements | Status |
| ---------------------------------------------------------------------------------- | --------- | ----------------------------- | --------- |
| [`@unlayer/react-elements`](https://www.npmjs.com/package/@unlayer/react-elements) | React | 18+ (incl. Server Components) | Available |
Vue, Angular, and Svelte wrappers may follow. The docs are structured so each gets its own subsection.
---
## Elements vs. the visual builder
| You want… | Reach for |
| ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------- |
| Designs authored by **non-technical users** in a drag-and-drop UI | The [visual builder](/builder/installation) |
| Designs authored by **developers in code**, lived alongside the rest of your codebase, version-controlled and reviewable | Elements |
| **Both** — designers in the editor, devs in code, sharing the same design JSON | Both — they round-trip via `renderToJson` / Unlayer's load API |
---
## Quick start
```tsx
Email,
Row,
Column,
ColumnLayouts,
Heading,
Paragraph,
Button,
renderToHtml,
} from '@unlayer/react-elements';
function WelcomeEmail() {
return (
Welcome!
);
}
const html = renderToHtml();
```
---
## Three rendering modes
Three semantic root wrappers, each tuned for a different output. The same content components (`Row`, `Column`, `Heading`, `Paragraph`, `Button`, etc.) work inside all three — the wrapper decides the mode.
| Wrapper | Output | Target |
| ------------ | -------------------- | ------------------------------------------------- |
| `` | Table-based HTML | Email clients (Outlook, Gmail, Yahoo, Apple Mail) |
| `` | `div` + flexbox HTML | Responsive web pages |
| `` | Print-tuned HTML | PDF generation |
For explicit `mode` control or threading an SSR config through, use the lower-level `` primitive.
---
## Structural shape
Every tree follows the same nesting:
```html
...content components...
```
The renderer relies on this shape to produce email-safe output; skipping levels (content directly inside `Row`, `Row` directly inside `Email`) throws at validation time.
---
## Render functions
Pure synchronous functions that turn a component tree into:
- **HTML** — `renderToHtml`, `renderToHtmlParts`
- **Plain text** — `renderToPlainText` (for the `text/plain` part of a multipart email)
- **Design JSON** — `renderToJson`, `renderRowToJson` (loads into the visual editor for round-tripping)
No client-side runtime is required.
---
## Highlights
- **15 components** — `Button`, `Heading`, `Paragraph`, `Image`, `Divider`, `Social`, `Menu`, `Table`, `Video`, `Html`, plus layout primitives.
- **Server Components friendly** — works with Next.js App Router, Remix, and any SSR framework. Zero client-side JS required.
- **Clean HTML output** — no React hydration markers.
- **TypeScript-first** — full type definitions and autocomplete for every prop.
- **Tiny** — under 50KB ESM, tree-shakeable.
---
## Where to go next
- **React** → [Installation](/elements/react/installation) · [Quickstart](/elements/react/quickstart)
- Vue, Angular, and Svelte wrappers may follow.
## Related
- **[Cloud API](/server/cloud-api/reference)** — export the designs you build with Elements to PDF, image, or ZIP.
- **[Design Schema](/design-schema/)** — the design JSON format Elements emits via `renderToJson()`.
---
## Button(Content)
```tsx render="@unlayer/react-elements" prerender background="transparent"
```
| Prop | Type | Default |
| ---------------------- | ---------------------------------- | --------------------------------------------------------------- |
| `text` | `string` | `"Button"` (or use children) |
| `href` | `string \| Href` | — |
| `backgroundColor` | `string` | `"#0879A1"` |
| `color` | `string` | `"#FFFFFF"` |
| `hoverBackgroundColor` | `string` | `"#0879A1"` (same as base — set explicitly for a visible hover) |
| `hoverColor` | `string` | `"#FFFFFF"` (same as base) |
| `fontSize` | `string` | `"14px"` |
| `fontWeight` | `number` | `400` |
| `fontFamily` | `{ label: string, value: string }` | — |
| `lineHeight` | `string` | `"120%"` |
| `padding` | `string` | `"10px 20px"` |
| `borderRadius` | `string` | `"4px"` |
| `textAlign` | `"left" \| "center" \| "right"` | `"center"` |
`href` accepts a plain string (auto-wrapped) or `{ name: "web", values: { href, target } }` if you need target control.
A note on `textAlign`: only `` and `` actually justify the rendered output (`text-align: justify`). `