From your first "Hello World" expression all the way to cross-layer FeatureSet queries, HTML-styled returns, and full dynamic styling dictionaries.
You know Experience Builder. Now let's teach your widgets to think. No prior coding experience needed — we'll explain every single line.
You've built EB apps. Text widgets show static text. Lists show rows of data. Maps show features. But everything just sits there.
What if a Text widget could count what someone selected and show their names? What if List items could change color automatically based on a field? What if a Button showed a different icon for each feature?
That's Advanced Formatting. And Arcade is the language you write it in.
Right now your widgets are a printed poster — same thing every time. Arcade turns them into a smart screen, like how your phone shows different info based on what's happening. Your widgets will react to selections, data values, and user actions.
Arcade is NOT JavaScript, NOT Python — it's Esri's own lightweight language designed specifically for GIS. It's simple, secure, and purpose-built. If you've ever used an Excel formula, you can learn Arcade.
Arcade scripts that calculate values and display them as widget content — text, image URLs, button labels.
Change colors, borders, icons, and backgrounds based on data. Some with Arcade, some with zero code.
We'll use Esri's pre-made template so we can focus on learning Arcade, not building layouts. It has a map of US statistical areas, a List widget, and blank Text widgets waiting for us.
Try advanced formattingBefore we touch any widgets, let's understand the three most important concepts in Arcade. Everything else builds on these.
Named containers that hold data. Like labeling a box: var name = "Jeff"
Sends a value back to the widget. Whatever you return is what the widget shows.
How you connect to your data. Each data source has a unique ID you can reference.
Let's write the simplest possible Arcade expression. Click any Text widget, click the Arcade button {} in the toolbar, and type:
return "Hello, Experience Builder! 🎉"
Click Insert. The widget now shows your text. That's it — you just wrote an Arcade expression! Now let's add a variable:
// Create a variable — a named container for data var greeting = "Welcome to" var appName = "My Dashboard" // Glue them together with + and return the result return greeting + " " + appName
var creates a variable. You pick the name, you assign the value with =return ends the script and sends the result to the widget. You can only return once.// starts a comment — notes for humans that Arcade ignores completely+ joins text together (called concatenation)return do?This is where Arcade starts getting useful. if lets your script make decisions: "If this is true, do this. Otherwise, do that."
var population = 75000 if (population > 50000) { return "Metropolitan area" } else { return "Micropolitan area" }
It's exactly like a fork in the road. The condition inside the parentheses is the question: "Is population greater than 50,000?" If yes, take the left path. If no, take the right path. The curly braces { } are the walls of each path.
You can chain multiple conditions with else if:
var pop = 250000 if (pop > 1000000) { return "Major Metro" } else if (pop > 250000) { return "Large City" } else if (pop > 50000) { return "Small City" } else { return "Town" }
There's also a shortcut called IIf (Inline If) for simple decisions:
// IIf(condition, valueIfTrue, valueIfFalse) var label = IIf(population > 50000, "Metro", "Micro") return label
| Operator | Meaning | Example |
|---|---|---|
== | Equals | status == "Open" |
!= | Not equals | type != "Closed" |
> | Greater than | pop > 50000 |
< | Less than | age < 35 |
>= | Greater or equal | score >= 90 |
&& | AND (both true) | pop > 100 && status == "Open" |
|| | OR (either true) | type == "A" || type == "B" |
Now we put it all together — real data, real widget. We'll make the "Text Title" widget respond to what the user selects on the map.
{} in the widget toolbar// Ask EB: "what features did the user select?" var selected = $dataSources["dataSource_2-197c022ade6-layer-2"].selectedFeatures; // Nothing selected? Show a friendly default if (Count(selected) == 0) { return "🌎 All Regions" } // Something selected — grab up to 3 names var names = []; var i = 0; for (var f in selected) { if (i < 3) { Push(names, f.NAME); } i++; } var label = "📍 Selected: " + Concatenate(names, ", "); if (i > 3) { label += " + " + (i - 3) + " more"; } return label;
$dataSources["..."].selectedFeatures — this is how you ask EB "what did the user click?" The long string is the data source ID (unique to each layer)Count() returns how many items are in a set. If zero, nothing's selected.[] creates an empty array (list). We'll fill it with names.for loop goes through each selected feature. f.NAME grabs the NAME field. Push() adds it to our list.Concatenate() joins the array into one string with commas between.Turn on Live view (play button, top right). Use the map's rectangle-select tool to select some areas. Watch the text change in real time! Select different numbers of areas — notice how it caps at 3 names and adds "+ X more".
You just used a loop in Lesson 4 without us making a big deal about it. Let's zoom in on how loops work, because they're everywhere in Arcade.
// The "for...in" loop visits every feature in a set var total = 0 for (var feature in someFeatureSet) { // This code runs once for EACH feature total += feature.POPULATION // += means "add to existing value" } return "Total population: " + total
Imagine a stack of flash cards, each one representing a feature (row in your data). A loop picks up each card, lets you read it and do something with it, then moves to the next card. When the stack is empty, the loop is done.
for (var f in featureSet) — f is the current feature on each iterationf.FIELDNAME — access any field on that feature+= — shortcut for "add to the existing value" (same as total = total + f.POP)if inside loops to filter, count, or categorizeGood news — this one needs no Arcade at all. EB has a built-in conditional styling feature for List widgets. You point it at a field and say "if the value is X, paint it purple."
CBSA_TYPE as the indicatorBackground: #ccbaf0 (light purple)
Big metro areas, 50K+ people
Background: #c0dfed (light blue)
Smaller areas, 10K-50K people
Inside a List, you get a special variable: $feature. This represents the single feature for each list item. Outside a List, you work with the whole dataset via $dataSources. This is the most important distinction in EB Arcade.
The entire dataset. Used in standalone widgets for aggregations (Sum, Count).
One single feature/row. Used inside List items where each card = one feature.
Dynamic Image: Click the Image widget inside the List → Settings → Connect to data → Dynamic → URL → Arcade → Arcade Editor:
var type = $feature.CBSA_TYPE; if (type == "Metropolitan") { return "https://arcgis.com/sharing/rest/content/items/d5ca28bf46fa44ea8e0aa0b9a8e513fb/data"; } return "https://arcgis.com/sharing/rest/content/items/b385127f92de4460afba630cbe106b39/data"
Dynamic Button text: Click the Button widget → Settings → Text dropdown → Arcade:
var age = $feature.MED_AGE; return IIf(age > 55, "Elder", IIf(age < 35, "Young Area", "Mid-age Area"));
You now know: variables, return, if/else, IIf, loops, $dataSources vs $feature, and no-code conditional styling. That's a real foundation. The Intermediate section builds on every one of these concepts.
Styled returns, filter-aware patterns, aggregation functions, date math, and reusable functions. Your scripts will start doing real analytical work.
In Beginner, we returned plain text: return "hello". Now we'll return a dictionary — a package containing the text AND styling instructions.
// Instead of returning a string, return a dictionary return { value: "Growing Population", text: { color: "rgb(0, 128, 0)", // green bold: true, size: 18, // pixels italic: false, }, };
A dictionary is like a labeled filing cabinet. Each drawer has a name (the key) and contents (the value). The text drawer itself contains more drawers: color, bold, size. EB opens each drawer and applies what it finds.
| Property | Type | What it does |
|---|---|---|
value | String | The text content to display |
text.color | String | CSS color — hex, rgb(), or named colors |
text.bold | Boolean | true or false |
text.italic | Boolean | true or false |
text.underline | Boolean | true or false |
text.strike | Boolean | Strikethrough |
text.size | Number | Font size in pixels |
Now let's use this with real data — the "Population Growth" widget:
var selected = $dataSources["dataSource_2-197c022ade6-layer-2"].selectedFeatures; var all = $dataSources["dataSource_2-197c022ade6-layer-2"].layer; var useSelection = Count(selected) > 0; var targetSet = IIf(useSelection, selected, all); var pop2018 = Sum(targetSet, 'POPULATION'); var pop2010 = Sum(targetSet, 'POP2010'); var str = ""; var color = ""; var bold = true; if (IsEmpty(pop2018) || IsEmpty(pop2010)) { str = "No data"; color = "gray"; bold = false; } else if (pop2018 >= pop2010) { str = "📈 Growing Population"; color = "rgb(0,128,0)"; } else { str = "📉 Shrinking Population"; color = "rgb(220,20,60)"; } return { value: str, text: { color: color, bold: bold } };
Here's a problem: if your app has Filter widgets or spatial filters, the simple .layer approach ignores them. You'll show stats for ALL features even when the user filtered down to a subset. The fix is a helper function:
function getFilteredFeatureSet(ds) { var result = ds.layer; var queryParams = ds.queryParams; if (!IsEmpty(queryParams.where)) { result = Filter(result, queryParams.where); } if (!IsEmpty(queryParams.geometry)) { result = Intersects(result, queryParams.geometry); } return result; } // Now use it: var ds = $dataSources["dataSource_2-197c022ade6-layer-2"]; var filtered = getFilteredFeatureSet(ds); var selected = ds.selectedFeatures; var targetSet = IIf(Count(selected) > 0, selected, filtered);
queryParams.where = attribute filters (e.g., "STATUS = 'Open'")queryParams.geometry = spatial filters (e.g., features within a drawn polygon)From Esri's docs: "Filters and spatial filters applied to data sources or data views cannot be automatically added to profile data sources. Use the getFilteredFeatureSet function to manually synchronize the filter." Always use this pattern in production apps.
Instead of writing loops to add things up, Arcade has built-in functions that do it in one line. These work on FeatureSets (collections of features).
| Function | What it does | Example |
|---|---|---|
Sum(fs, 'field') | Add up all values | Sum(features, 'POPULATION') |
Count(fs) | Count features | Count(selected) |
Average(fs, 'field') | Calculate mean | Average(features, 'MED_AGE') |
Max(fs, 'field') | Highest value | Max(features, 'POPULATION') |
Min(fs, 'field') | Lowest value | Min(features, 'POP2010') |
Distinct(fs, 'field') | Unique values | Count(Distinct(fs, 'STATE')) |
First(fs) | First feature | First(selected).NAME |
var ds = $dataSources["YOUR_DS_ID"]; var features = ds.layer; var total = Count(features); var totalPop = Sum(features, 'POPULATION'); var avgAge = Round(Average(features, 'MED_AGE'), 1); var biggest = Max(features, 'POPULATION'); return total + " regions | Pop: " + Text(totalPop, '#,###') + " | Avg age: " + avgAge + " | Largest: " + Text(biggest, '#,###');
Text(1234567, '#,###') → 1,234,567. The Text() function formats numbers with commas, decimal places, percentages, currencies, and more. Like Excel's number formatting.
Division + if/else chains = classification. This is the "Population Density" widget from the template. The new idea here is protecting against division by zero.
// (Using filter helper and selection check from Lesson 9) var totalPop = Sum(targetSet, 'POPULATION'); var landArea = Sum(targetSet, 'SQMI'); // ⚡ CRITICAL: protect against divide-by-zero var density = IIf( IsEmpty(landArea) || landArea == 0, null, totalPop / landArea ); var str = ""; var color = ""; if (IsEmpty(density)) { str = "No data"; color = "gray"; } else if (density > 500) { str = "🏬 High Density"; color = "rgb(138,43,226)"; } else if (density >= 100) { str = "🏠 Moderate"; color = "rgb(255,165,0)"; } else { str = "🚜 Low Density"; color = "rgb(105,105,105)"; } return { value: str, text: { color: color, bold: true } };
Dividing by zero or null crashes your expression. The pattern IIf(IsEmpty(x) || x == 0, null, a / x) is your seatbelt. Use it every single time you divide.
Buttons can change their icon per feature. This requires two steps: define named icons in the UI, then reference them by name in Arcade.
Elder, Middle, Youngvar age = $feature.MED_AGE; var icon = IIf(age > 55, 'Elder', IIf(age < 35, 'Young', 'Middle')); return { icon: { name: icon, // must match your named icons exactly! size: 28, position: 'LEFT', // or 'RIGHT' } };
The returned string must be identical to the name you gave the icon in the UI. Case matters. If you named it "Elder" and return "elder", nothing shows.
Arcade has date functions for time-based displays. This is great for "last updated" labels and time-sensitive dashboards.
// "X days ago" pattern from Esri's docs var lastUpdated = $feature.EditDate; var daysAgo = Floor(DateDiff(Now(), lastUpdated, "days")); if (daysAgo == 0) { return "Updated today"; } if (daysAgo == 1) { return "Updated yesterday"; } if (daysAgo < 7) { return daysAgo + " days ago"; } if (daysAgo < 30) { return Floor(daysAgo/7) + " weeks ago"; } return Floor(daysAgo/30) + " months ago";
| Function | What it does |
|---|---|
Now() | Current date/time |
DateDiff(date1, date2, "unit") | Difference between dates. Units: "days", "hours", "minutes", "years" |
Floor(n) | Round down to nearest integer |
Year(date) / Month(date) | Extract parts of a date |
Text(date, 'MMM D, YYYY') | Format: "Jan 5, 2026" |
You've already used the getFilteredFeatureSet function. Let's understand how to write your own.
// Define a function — a reusable block of logic function classifyPop(pop) { if (pop > 1000000) { return "Major Metro"; } if (pop > 250000) { return "Large City"; } if (pop > 50000) { return "Small City"; } return "Town"; } // Now use it as many times as you want var label = classifyPop($feature.POPULATION); return label;
function name(parameters) — declare the function with inputs{ } runs when you call the functionreturn inside a function sends a value back to where it was calledYou now have: styled returns, filter-aware patterns, aggregation functions, math + classification, dynamic icons, date functions, and custom functions. These are the building blocks of every production EB app.
HTML-formatted returns, full style dictionaries with borders and backgrounds, hover states, the 10-expression limit, performance optimization, and real-world Red Cross operational patterns.
Beyond styled dictionaries, Arcade in EB can return actual HTML. This opens up rich formatting: links, images, lists, multi-color text, all in one Text widget.
var name = $feature.NAME; var pop = Text($feature.POPULATION, '#,###'); var type = $feature.CBSA_TYPE; var color = IIf(type == "Metropolitan", "#7c6cf0", "#4ecdc4"); return '<h3 style="margin:0;color:' + color + '">' + name + '</h3>' + '<p style="margin:4px 0;font-size:14px;color:#666;">' + 'Population: <strong>' + pop + '</strong> · ' + '<em>' + type + '</em></p>';
<h1> through <h6> — headings (style attribute OK)<p>, <span>, <strong>, <em>, <u>, <s> — text formatting<ul>, <ol>, <li> — lists<a href="..." target="..."> — links<img src="..." alt="..." width="..." height="..."> — inline images<br> — line breakstyle attribute for inline CSSOnly the tags listed above are allowed — no <div>, no <table>, no <script>. EB strips anything not on the whitelist. Also, use inline styles only — no <style> blocks.
Dynamic styling scripts can control much more than text color. Here's the full power available for each widget type:
| Widget | Properties you can control |
|---|---|
| Text | text (size, color, bold, italic, underline, strike), background (color, image, fillType) |
| Button | All Text props + icon (name, position, size, color), border (type, color, width per side), borderRadius |
| List item | background (color, image, fillType), border (type, color, width per side), borderRadius |
// A List item with full styling based on priority var priority = $feature.PRIORITY; var bgColor = ""; var borderColor = ""; if (priority == "High") { bgColor = "rgba(255,107,107,0.15)"; borderColor = "#ff6b6b"; } else if (priority == "Medium") { bgColor = "rgba(254,202,87,0.15)"; borderColor = "#feca57"; } else { bgColor = "rgba(0,214,143,0.15)"; borderColor = "#00d68f"; } return { background: { color: bgColor }, border: { borderLeft: { type: "solid", color: borderColor, width: 4 } }, borderRadius: { unit: "px", number: [8, 8, 8, 8] // [topLeft, topRight, bottomRight, bottomLeft] } };
Buttons and List items have multiple states: Default, Hover, and Selected. Each state can have its own dynamic style script.
// Brighter version of the default colors on hover var type = $feature.CBSA_TYPE; return { background: { color: IIf(type == "Metropolitan", "rgba(124,108,240,0.3)", "rgba(78,205,196,0.3)") }, text: { bold: true }, border: { border: { type: "solid", color: "#fff", width: 1 } } };
Each EB page supports a maximum of 10 Arcade expressions using the widget formatting profile. This includes both dynamic content AND dynamic style scripts. If you need more, here are your strategies:
If two Text widgets use the same aggregation, merge them into one widget that returns HTML with both values.
Arcade in the Add data window creates a FeatureSet data source that doesn't count toward the 10-expression limit. Calculate values once, reference everywhere.
If you use the same aggregation value in multiple places, use the Arcade option in the Add data window to calculate it once as a data source. Then reference that calculated data source in multiple widgets without using additional expressions.
Arcade expressions query your feature layers. Every query takes time. Here's how to keep your app fast.
Use Sum(), Count(), Average() — they run as single server queries. Chain FeatureSet operations. Limit fields when possible.
Don't loop through large FeatureSets manually. Don't write multiple expressions that query the same data. Don't fetch all fields if you only need one.
for loop downloads every feature to the client first.FeatureSetByName() costs nothing; calling Count() on it triggers the query.Count(Filter(features, "POP > 50000")) sends one query. Storing the filtered set, then counting = also one query (thanks to lazy evaluation).Console(variable) to inspect values in browser dev tools (F12 → Console tab). Remove in production.// ONE expression returning HTML with multiple stats // Instead of 3 separate Text widgets with 3 Arcade expressions var ds = $dataSources["YOUR_DS_ID"]; var features = ds.layer; var total = Count(features); var totalPop = Sum(features, 'POPULATION'); var avgAge = Round(Average(features, 'MED_AGE'), 1); return '<p style="font-size:24px;font-weight:bold;margin:0;">' + Text(totalPop, '#,###') + '</p>' + '<p style="font-size:13px;color:#888;margin:4px 0 0;">' + total + ' regions · Avg age ' + avgAge + '</p>';
Everything you've learned translates directly to disaster response and humanitarian operations. Here are production-ready patterns for common Red Cross EB apps.
// Shelter status — use no-code Condition tab with STATUS field // Open → background #d4edda (green) // At Capacity → background #fff3cd (yellow) // Closed → background #f8d7da (red) // Or with Arcade for more control: var status = $feature.STATUS; var occupancy = $feature.CURRENT_POP / $feature.CAPACITY; var bg = "rgba(212,237,218,0.3)"; // green default if (status == "Closed") { bg = "rgba(248,215,218,0.3)"; } else if (occupancy > 0.9) { bg = "rgba(255,243,205,0.4)"; // near capacity = yellow } else if (occupancy > 0.75) { bg = "rgba(255,243,205,0.2)"; // filling up = light yellow } return { background: { color: bg }, border: { borderLeft: { type: "solid", width: 4, color: IIf(status == "Closed", "#dc3545", IIf(occupancy > 0.9, "#ffc107", "#28a745")) } } };
function getFilteredFeatureSet(ds) { var result = ds.layer; var qp = ds.queryParams; if (!IsEmpty(qp.where)) { result = Filter(result, qp.where); } if (!IsEmpty(qp.geometry)) { result = Intersects(result, qp.geometry); } return result; } var ds = $dataSources["YOUR_SHELTER_DS"]; var filtered = getFilteredFeatureSet(ds); var selected = ds.selectedFeatures; var target = IIf(Count(selected) > 0, selected, filtered); var totalShelters = Count(target); var openShelters = Count(Filter(target, "STATUS = 'Open'")); var totalPop = Sum(target, 'CURRENT_POP'); var totalCap = Sum(target, 'CAPACITY'); var pctFull = IIf(totalCap > 0, Round((totalPop / totalCap) * 100, 0), 0); var pctColor = IIf(pctFull > 90, "#dc3545", IIf(pctFull > 75, "#ffc107", "#28a745")); return '<h3 style="margin:0 0 8px;">Shelter Operations</h3>' + '<p style="margin:0;font-size:14px;">' + '<strong>' + openShelters + '</strong> of ' + totalShelters + ' shelters open · ' + '<strong>' + Text(totalPop, '#,###') + '</strong> residents' + '</p>' + '<p style="margin:4px 0 0;font-size:13px;color:' + pctColor + ';font-weight:bold;">' + pctFull + '% capacity</p>';
You've gone from "what is Arcade?" to HTML returns, full style dictionaries, performance optimization, and production shelter dashboards. These 21 lessons cover the entire scope of what's possible with Arcade in Experience Builder as of 2026. The patterns are modular — mix and match them for any operational tool you need to build.