Build Custom Tools Overview Custom tools are drag-and-drop content blocks you create for the Unlayer editor. Each tool needs: A renderer (what users see in the editor) Exporters (HTML output — must be table-based for email) Property editors (the settings panel) Complete Example: Product Card This is a fully working custom tool with an image, title, price, and buy button: unlayer . registerTool ( { name : 'product_card' , label : 'Product Card' , icon : 'fa-shopping-cart' , supportedDisplayModes : [ 'web' , 'email' ] , options : { content : { title : 'Content' , position : 1 , options : { productTitle : { label : 'Product Title' , defaultValue : 'Product Name' , widget : 'text' , // → values.productTitle = 'Product Name' } , productImage : { label : 'Image' , defaultValue : { url : 'https://via.placeholder.com/300x200' } , widget : 'image' , // → values.productImage.url = 'https://...' } , price : { label : 'Price' , defaultValue : '$99.99' , widget : 'text' , // → values.price = '$99.99' } , buttonText : { label : 'Button Text' , defaultValue : 'Buy Now' , widget : 'text' , } , buttonLink : { label : 'Button Link' , defaultValue : { name : 'web' , values : { href : 'https://example.com' , target : '_blank' } } , widget : 'link' , // → values.buttonLink.values.href = 'https://...' } , } , } , colors : { title : 'Colors' , position : 2 , options : { titleColor : { label : 'Title Color' , defaultValue : '#333333' , widget : 'color_picker' , // → values.titleColor = '#333333' } , buttonBg : { label : 'Button Background' , defaultValue : '#007bff' , widget : 'color_picker' , } , } , } , } , values : { } , renderer : { Viewer : unlayer . createViewer ( { render ( values ) { return `
;
}
,
}
)
,
exporters
:
{
web
(
values
)
{
return
;
}
,
email
(
values
)
{
// Email MUST use tables — divs break in Outlook/Gmail
return
|
| |
${ values . productTitle } | |
|
${ values . price } | |
|
;
}
,
}
,
head
:
{
css
(
values
)
{
return
${ values . _meta . htmlID } img { max-width: 100%; height: auto; } ` ; } , js ( values ) { return '' ; } , } , } , validator ( data ) { const { values , defaultErrors } = data ; const errors = [ ] ; if ( ! values . productTitle ) { errors . push ( { id : 'PRODUCT_TITLE_REQUIRED' , icon : 'fa-warning' , severity : 'ERROR' , title : 'Missing product title' , description : 'Product title is required' , } ) ; } if ( ! values . productImage ?. url ) { errors . push ( { id : 'PRODUCT_IMAGE_REQUIRED' , icon : 'fa-warning' , severity : 'ERROR' , title : 'Missing product image' , description : 'Product image is required' , } ) ; } return [ ... errors , ... defaultErrors ] ; } , } ) ; Register it at init time with the custom# prefix: unlayer . init ( { tools : { 'custom#product_card' : { // REQUIRED: custom# prefix data : { apiEndpoint : '/api/products' , // Custom data accessible in renderer } , properties : { // Override default property values or dropdown options } , } , } , } ) ; Widget Value Access Reference How to read each widget type's value in your renderer: Widget Default Value Access in render(values) text 'Hello' values.myField → 'Hello' rich_text '
Hello
' values.myField → 'Hello
' html '< label
{ label } : { value } px </ label
< input type = " range " min = { data . min || 0 } max = { data . max || 100 } value = { parseInt ( value ) } onChange = { ( e ) => updateValue ( e . target . value + 'px' ) } /> </ div
) ; unlayer . registerPropertyEditor ( { name : 'range_slider' , Widget : RangeSlider , } ) ; // Use in your tool: borderRadius : { label : 'Corner Radius' , defaultValue : '4px' , widget : 'range_slider' , data : { min : 0 , max : 50 } , } , Validator Return Format Each error must include id , icon , severity , title , and description : validator ( data ) { const { values , defaultErrors } = data ; const errors = [ ] ; if ( ! values . productTitle ) { errors . push ( { id : 'PRODUCT_TITLE_REQUIRED' , // Unique error ID icon : 'fa-warning' , // FontAwesome icon severity : 'ERROR' , // 'ERROR' | 'WARNING' title : 'Missing product title' , // Short label description : 'Product title is required' , // Detailed message } ) ; } if ( values . price && ! values . price . startsWith ( '$' ) ) { errors . push ( { id : 'PRICE_MISSING_CURRENCY' , icon : 'fa-dollar-sign' , severity : 'WARNING' , title : 'Missing currency symbol' , description : 'Price should include currency symbol' , labelPath : 'price' , // Optional — highlights the property in the panel } ) ; } return [ ... errors , ... defaultErrors ] ; // Merge with built-in errors } Email-Safe HTML Patterns Email clients (Outlook, Gmail) require table-based HTML. Copy-paste these patterns: Button: < table cellpadding = " 0 " cellspacing = " 0 " border = " 0 "
< tr
< td style = " background :
007bff
; border-radius : 4 px ; "
< a href = " URL " style = " display : inline-block ; color :
fff
; padding : 12 px 24 px ; text-decoration : none ; "
Button Text </ a
</ td
</ tr
</ table
Two columns: < table width = " 100% " cellpadding = " 0 " cellspacing = " 0 "
< tr
< td width = " 50% " valign = " top " style = " padding : 10 px ; "
Left </ td
< td width = " 50% " valign = " top " style = " padding : 10 px ; "
Right </ td
</ tr
</ table
Safe CSS properties: color , background-color , font-size , font-family , font-weight , text-align , padding , margin , border , width , max-width , display: block/inline-block . Unsafe (avoid in email): flexbox , grid , position , float , box-shadow , border-radius (partial support), calc() . Common Mistakes Mistake Fix Missing custom# prefix Tools MUST use custom#my_tool in tools config at init Div-based email exporter Email exporters MUST return table-based HTML Forgetting _meta.htmlID Scope CSS:
${values._meta.htmlID}
Hardcoded values in renderer Use values object — let property editors drive content Wrong dropdown options format Pass options via unlayer.init() under tools['custom#name'].properties.prop.editor.data.options Troubleshooting Problem Fix Tool doesn't appear in editor Check supportedDisplayModes includes current mode Properties panel is empty Check options structure — needs group → options nesting Custom editor doesn't update Ensure updateValue() is called with the new value Exported HTML looks different Check both Viewer.render() and exporters.email/web() Resources Create Custom Tool Property Editors Advanced Options Built-in Tools