AEM Dialogs — Deep Dive
Build any dialog from scratch: Granite UI fields, tabs, validation — and a multifield inside a multifield. Every node explained, all the code included.
▶ Watch the full episode (EP05):
Last episode we built our first component but copied its dialog from another one. This time we build a brand new Teaser component with a dialog written node by node — six field types, four tabs, validation, and a multifield nested inside another multifield. All the code below is complete and working; the full reasoning behind every line is in the video.
What a dialog really is
A dialog is a node tree in the repository, stored under your component at cq:dialog. AEM renders a form from it: every field is one node, every behavior is a property. Building a dialog is just creating nodes.
The shape is always the same:
+ cq:dialog
+ content (a container)
+ items
+ tabs (the tab bar)
+ items
+ content_tab (one tab)
+ items
+ title (one field)Node names are free — the tab label comes from jcr:title. The items nodes are plain holders (”these are my children, in this order”). Everything else is decided by sling:resourceType.
Step 1 — The component and dialog v1
The component — /apps/aeminsider/components/teaser/.content.xml:
xml
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:cq="http://www.day.com/jcr/cq/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0"
jcr:primaryType="cq:Component"
jcr:title="Teaser"
componentGroup="AEM Insider"/>The first version of the dialog — two tabs, one textfield each. _cq_dialog/.content.xml:
xml
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
jcr:primaryType="nt:unstructured"
jcr:title="Teaser"
sling:resourceType="cq/gui/components/authoring/dialog">
<content
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/container">
<items jcr:primaryType="nt:unstructured">
<tabs
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/tabs"
maximized="{Boolean}true">
<items jcr:primaryType="nt:unstructured">
<content_tab
jcr:primaryType="nt:unstructured"
jcr:title="Content"
sling:resourceType="granite/ui/components/coral/foundation/container"
margin="{Boolean}true">
<items jcr:primaryType="nt:unstructured">
<title
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
fieldLabel="Title"
name="./title"
emptyText="e.g. Why AEM dialogs matter"/>
</items>
</content_tab>
<link_tab
jcr:primaryType="nt:unstructured"
jcr:title="Link & CTA"
sling:resourceType="granite/ui/components/coral/foundation/container"
margin="{Boolean}true">
<items jcr:primaryType="nt:unstructured">
<ctaLabel
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
fieldLabel="Button label"
name="./ctaLabel"
emptyText="Read more"/>
</items>
</link_tab>
</items>
</tabs>
</items>
</content>
</jcr:root>The first HTL — teaser.html:
html
<div class="aemi-teaser">
<h2>${properties.title}</h2>
</div>Drag the Teaser onto a page, Configure, type a title, save — it renders. That’s the round trip: dialog → repository → HTL.
Granite UI and the name property
The fields come from Granite UI, AEM’s built-in library of form fields under libs — you use one by pointing a node at it with sling:resourceType. Every field and every property it supports is listed in the reference docs — bookmark them: https://developer.adobe.com/experience-manager/reference-materials/6-5/granite-ui/api/jcr_root/libs/granite/ui/index.html
The one property to understand properly is name — it decides where the value is saved. ./title means “save a property called title on this component’s node”, and HTL reads it back with properties.title — the two must match exactly, including case. Most dialog bugs are this one property.
The six fields of this episode
FieldResource type (full)StoresTextfieldgranite/ui/components/coral/foundation/form/textfieldTextRich text (RTE)cq/gui/components/authoring/dialog/richtextHTMLPathfieldgranite/ui/components/coral/foundation/form/pathfieldA pathCheckboxgranite/ui/components/coral/foundation/form/checkboxtrue / falseSelect (dropdown)granite/ui/components/coral/foundation/form/selectOne value from a listMultifieldgranite/ui/components/coral/foundation/form/multifieldA list of items
Three of these need more than a flat node:
RTE — its features (bold, lists, links) are switched on through an
rtePluginschild node, one child per plugin. Enable only what your CSS supports.Select — each option is a child node with a
text(what the author reads) and avalue(what gets saved), so you can rename options later without breaking content.Checkbox — no
fieldLabel(it usestext), and the values need the{Boolean}type hint. If a checkbox saves the string"false", HTL treats non-empty text as true and your “hidden” button renders anyway. Store real booleans.
Step 2 — The full dialog (everything except the multifield)
xml
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
jcr:primaryType="nt:unstructured"
jcr:title="Teaser"
sling:resourceType="cq/gui/components/authoring/dialog">
<content
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/container">
<items jcr:primaryType="nt:unstructured">
<tabs
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/tabs"
maximized="{Boolean}true">
<items jcr:primaryType="nt:unstructured">
<!-- TAB 1: CONTENT -->
<content_tab
jcr:primaryType="nt:unstructured"
jcr:title="Content"
sling:resourceType="granite/ui/components/coral/foundation/container"
margin="{Boolean}true">
<items jcr:primaryType="nt:unstructured">
<title
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
fieldLabel="Title"
name="./title"
emptyText="e.g. Why AEM dialogs matter"/>
<description
jcr:primaryType="nt:unstructured"
sling:resourceType="cq/gui/components/authoring/dialog/richtext"
fieldLabel="Description"
name="./description"
useFixedInlineToolbar="{Boolean}true">
<rtePlugins jcr:primaryType="nt:unstructured">
<format jcr:primaryType="nt:unstructured" features="bold,italic"/>
<lists jcr:primaryType="nt:unstructured" features="*"/>
<links jcr:primaryType="nt:unstructured" features="modifylink,unlink"/>
</rtePlugins>
</description>
</items>
</content_tab>
<!-- TAB 2: LINK & CTA -->
<link_tab
jcr:primaryType="nt:unstructured"
jcr:title="Link & CTA"
sling:resourceType="granite/ui/components/coral/foundation/container"
margin="{Boolean}true">
<items jcr:primaryType="nt:unstructured">
<linkUrl
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/pathfield"
fieldLabel="Link to page"
name="./linkUrl"
rootPath="/content"/>
<showCta
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/checkbox"
text="Show call-to-action button"
name="./showCta"
value="{Boolean}true"
uncheckedValue="{Boolean}false"/>
<ctaLabel
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
fieldLabel="Button label"
name="./ctaLabel"
emptyText="Read more"/>
</items>
</link_tab>
<!-- TAB 3: STYLE -->
<style_tab
jcr:primaryType="nt:unstructured"
jcr:title="Style"
sling:resourceType="granite/ui/components/coral/foundation/container"
margin="{Boolean}true">
<items jcr:primaryType="nt:unstructured">
<style
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/select"
fieldLabel="Teaser style"
name="./style">
<items jcr:primaryType="nt:unstructured">
<default jcr:primaryType="nt:unstructured" text="Default" value="default" selected="{Boolean}true"/>
<highlight jcr:primaryType="nt:unstructured" text="Highlight" value="highlight"/>
<dark jcr:primaryType="nt:unstructured" text="Dark" value="dark"/>
</items>
</style>
</items>
</style_tab>
</items>
</tabs>
</items>
</content>
</jcr:root>Tip: keep the pathfield’s rootPath as narrow as possible — what authors can’t reach, they can’t break.
The HTL
html
<div class="aemi-teaser aemi-teaser--${properties.style || 'default'}">
<h2 class="aemi-teaser__title">${properties.title}</h2>
<div class="aemi-teaser__description">${properties.description @ context='html'}</div>
<a class="aemi-teaser__cta"
data-sly-test="${properties.showCta && properties.linkUrl}"
href="${properties.linkUrl @ extension='html'}">${properties.ctaLabel || 'Read more'}</a>
</div>Every line maps to one dialog field. The wrapper turns the Style dropdown’s value into a CSS class, with || 'default' as the fallback. The description needs @ context='html' because the RTE stores intentional HTML — use it for RTE output, and only for RTE output. The CTA renders only when the checkbox is ticked and a link exists, with “Read more” as the label fallback.
The styles — for today only
Scoped under aemi-teaser; the select’s value becomes the modifier class on the wrapper. Inline <style> is fine for learning — real projects ship CSS through clientlibs (later in the series).
html
<style>
.aemi-teaser {
max-width: 640px;
padding: 24px;
border: 1px solid #ddd;
border-radius: 8px;
}
.aemi-teaser__title { margin: 0 0 12px; }
.aemi-teaser__description { color: #444; line-height: 1.5; }
.aemi-teaser__cta {
display: inline-block;
margin-top: 16px;
padding: 10px 22px;
background: #D32F2F;
color: #fff;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
}
.aemi-teaser__highlights { margin: 16px 0 0; padding-left: 20px; }
.aemi-teaser__highlight { margin: 6px 0; }
.aemi-teaser__highlight a { color: #D32F2F; }
.aemi-teaser__points { margin: 6px 0 0; padding-left: 18px; }
/* the Style dropdown's value becomes one of these classes */
.aemi-teaser--highlight { border: 2px solid #D32F2F; background: #fdf3f3; }
.aemi-teaser--dark {
background: #16171A;
border-color: #16171A;
color: #fff;
}
.aemi-teaser--dark .aemi-teaser__title { color: #fff; }
/* the RTE outputs raw <p>/<li> tags — global page styles hit those
directly, so the dark variant must target them explicitly: */
.aemi-teaser--dark .aemi-teaser__description,
.aemi-teaser--dark .aemi-teaser__description p,
.aemi-teaser--dark .aemi-teaser__description li,
.aemi-teaser--dark .aemi-teaser__highlight { color: rgba(255,255,255,0.8); }
</style>The multifield
The simple form first
A multifield whose field node is just a textfield — no composite, the field itself carries the name:
+ highlights
- sling:resourceType = "granite/ui/components/coral/foundation/form/multifield"
+ field
- sling:resourceType = "granite/ui/components/coral/foundation/form/textfield"
- name = "./highlights"That stores one multi-value property — a plain list of strings — and reading it is trivial:
html
<ul data-sly-list.item="${properties.highlights}">
<li>${item}</li>
</ul>But one field per row is all you get. The moment a row needs a text and a link — or a nested list — you need the second mode.
The composite multifield
Two changes: composite="{Boolean}true" (each row becomes a child node), and a container node that must be called field, carrying the name. Naming convention straight from the Granite docs: the field container takes the dot-slash (./highlights, ./points); the fields inside use plain names (text, link) — they’re relative to each item automatically. Nesting is the same pattern one level deeper, and officially supported for composite multifields.
The fourth tab, added under tabs/items:
xml
<highlights_tab
jcr:primaryType="nt:unstructured"
jcr:title="Highlights"
sling:resourceType="granite/ui/components/coral/foundation/container"
margin="{Boolean}true">
<items jcr:primaryType="nt:unstructured">
<highlights
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/multifield"
fieldLabel="Highlights"
composite="{Boolean}true">
<field
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/container"
name="./highlights">
<items jcr:primaryType="nt:unstructured">
<text
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
fieldLabel="Highlight text"
name="text"
required="{Boolean}true"/>
<link
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/pathfield"
fieldLabel="Link (optional)"
name="link"
rootPath="/content"/>
<points
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/multifield"
fieldLabel="Sub-points"
composite="{Boolean}true">
<field
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/container"
name="./points">
<items jcr:primaryType="nt:unstructured">
<text
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
fieldLabel="Point"
name="text"/>
</items>
</field>
</points>
</items>
</field>
</highlights>
</items>
</highlights_tab>What saving produces
./highlights becomes a child node; every row becomes an item inside it; the nested ./points becomes a node inside each item. The name in the dialog becomes the node in the repository:
+ teaser
- title, description, linkUrl, showCta, style, ...
+ highlights
+ item0
- text = "Built from scratch"
- link = "/content/..."
+ points
+ item0
- text = "No copy-paste"
+ item1
- text = "Validation included"Rendering it — the multifield HTL
properties reads simple values and cannot see child nodes — so for the composite multifield we walk the node tree ourselves, three loops:
html
<sly data-sly-list.child="${resource.children}">
<ul class="aemi-teaser__highlights"
data-sly-test="${child.name == 'highlights'}"
data-sly-list.item="${child.children}">
<li class="aemi-teaser__highlight">
<a data-sly-test="${item.valueMap.link}"
href="${item.valueMap.link @ extension='html'}">${item.valueMap.text}</a>
<sly data-sly-test="${!item.valueMap.link}">${item.valueMap.text}</sly>
<!-- nested multifield: same pattern, one level deeper -->
<sly data-sly-list.grp="${item.children}">
<ul class="aemi-teaser__points"
data-sly-test="${grp.name == 'points'}"
data-sly-list.point="${grp.children}">
<li>${point.valueMap.text}</li>
</ul>
</sly>
</li>
</ul>
</sly>Loop one finds the highlights node among resource.children. Loop two goes over its children — one item per row — reading values through item.valueMap (items are nodes, so properties doesn’t apply); the anchor renders when a link exists, plain text when it doesn’t. Loop three repeats the move for the nested points. The pattern: children → test the name → children again → valueMap.
Honest verdict: three loops to render two lists works fine at this size, but this is exactly the job a Sling Model does in a few clean lines of Java — its own episode, later in the series.
Validation
Granite gives you the basics as simple properties: required (can’t save while empty — red ring, and the field’s tab gets flagged), maxlength (hard character limit), emptyText (placeholder) and fieldDescription (info-icon tooltip). Only two fields change:
xml
<title
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
fieldLabel="Title"
name="./title"
emptyText="e.g. Why AEM dialogs matter"
required="{Boolean}true"
maxlength="{Long}60"
fieldDescription="Shown as the teaser heading. Keep it under 60 characters."/>
<linkUrl
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/pathfield"
fieldLabel="Link to page"
name="./linkUrl"
rootPath="/content"
fieldDescription="The page this teaser points to. Required if the button is shown."/>Note the types — required is a Boolean, maxlength is a Long. Validation works inside multifields exactly like everywhere else (the highlight text already had required). Conditional rules — required only when the checkbox is ticked — need JavaScript via a clientlib, coming later in the series; until then, fieldDescription documents the rule.
Export it
Same flow as EP04: Package Manager → package teaser-component → filter /apps/aeminsider/components/teaser → build → download → copy into ui.apps. The dialog inside the package is exactly the XML from this issue — CRXDE was never a separate world.
What’s next
We dragged the Teaser onto the page and it just worked — but why was it allowed there? That’s decided in one place: the page’s template. Next episode: Editable Templates — how every AEM page gets its structure. Templates, policies, structure, initial content — and the template policy we’ve been quietly using since episode four.
The full series playlist: youtube.com/playlist?list=PLS3Ri9kRH79YJ-FGLcymgkb29ZkgZ64vZ
Website: https://aeminsider.com
Granite UI reference docs: https://developer.adobe.com/experience-manager/reference-materials/6-5/granite-ui/api/jcr_root/libs/granite/ui/index.html
If this series is helping you, subscribe here on Substack and on YouTube — and reply with your questions. I read all of them.
