Canvas Controls Are Knockout.js Widgets, and Two of Them Are Already React
What a Power Apps control actually is, read straight from its source.
A canvas control feels like a black box. You set Fill, something turns blue. You set Wrap, text stops overflowing. What happens in between is not documented anywhere.
It is, however, shipped to you. Every .msapp contains References/Templates.json, and every entry in it is the complete definition of a control — including its literal HTML.
Here is how the label control begins:
<widget xmlns="http://openajax.org/metadata" spec="1.0"
id="http://microsoft.com/appmagic/label"
name="label"
jsClass="AppMagic.Controls.Label"
version="2.5.1"
styleable="true"
runtimeCost="1"
xmlns:appMagic="http://schemas.microsoft.com/appMagic">
That is an OpenAJAX Metadata widget declaration. OpenAJAX was an industry standard for describing reusable web widgets, from the OpenAjax Alliance. The Alliance wound down over a decade ago. Power Apps controls are still declared in its schema, in production, today.
The internal codename is right there too: AppMagic. Every jsClass is AppMagic.Controls.Something.
The rendering layer is Knockout
Immediately after the metadata comes the actual markup, as a CDATA block, and it is Knockout.js:
<div class="appmagic-label no-focus-outline"
touch-action="pan-x pan-y"
tabIndex="-1"
data-bind="
style: {
fontFamily: properties.Font,
fontSize: properties.Size,
color: autoProperties.Color,
fontWeight: properties.FontWeight,
fontStyle: properties.Italic,
textAlign: properties.Align,
paddingTop: properties.PaddingTop,
lineHeight: properties.LineHeight,
overflowY: properties.Overflow,
display: properties.Overflow() === 'auto' ? 'block' : ''
},
css: {
top: properties.AutoHeight() || properties.VerticalAlign() === 'top',
middle: !properties.AutoHeight() && properties.VerticalAlign() === 'middle',
bottom: !properties.AutoHeight() && properties.VerticalAlign() === 'bottom',
disabled: viewState.displayMode() === AppMagic.Constants.DisplayMode.Disabled,
readOnly: viewState.displayMode() === AppMagic.Constants.DisplayMode.View,
underline: properties.Underline,
strikethrough: properties.Strikethrough
},
attr: {
title: properties.Tooltip,
role: properties.TabIndex() >= 0 ? 'button' : 'presentation'
},
event: { click: handleClick }"
>
data-bind, properties.X() observables, css: / attr: / event: handlers — that is textbook Knockout, the MVVM library Microsoft shipped in 2010. Your Size property is a Knockout observable feeding fontSize. DisplayMode is a computed driving CSS classes.
Two details in there are worth calling out on their own.
autoProperties.Color, not properties.Color. Some properties resolve through an “auto” layer before binding. The template’s capability list explains which: autoFill, autoBorders, autoFocusedBorders, autoPointerViewState, autoDisabledViewState, autoBorderRadius. The framework synthesises hover/pressed/disabled variants rather than the control handling them.
role: TabIndex() >= 0 ? 'button' : 'presentation'. Accessibility semantics are inferred from tab index. And the image control has a comment admitting it:
When the image is meant to be clickable, a
<button>is overlaid on top of the actual image. Unfortunately, the control does not know ifOnSelectbehavior is specified by the user, so we useTabIndex >= 0as a heuristic that the image should be a button.
There it is, in Microsoft’s own words: the platform cannot tell whether you wired up OnSelect, so it guesses from TabIndex. If your clickable image is invisible to a screen reader, that heuristic is why.
Headings are a property, and they emit real HTML
The label template branches on Role:
<!-- ko if: properties.Role() === 'heading1' -->
<h1 class="appmagic-label-text" data-control-part="text"
data-bind="{ inlineEditText: properties.Text }"></h1>
<!-- /ko -->
<!-- ko if: properties.Role() === 'heading2' -->
<h2 ...></h2>
<!-- /ko -->
h1 through h4, plus a plain div when Role is unset. So Role is not decorative metadata — it changes the emitted tag. This is the single highest-leverage accessibility control in canvas apps, it costs nothing, and almost nobody sets it.
Also note data-control-part="text". Every interactive region carries one — data-control-part="button" on the button, and so on. Those are stable test hooks. If you automate canvas apps with Playwright or Selenium, [data-control-part] is a far better selector than the generated ids everyone currently fights with.
The React migration is visible in the file list
Each template declares its dependencies:
<requires>
<require type="css" src="css/label.css" />
<require type="javascript" src="js/label.js" />
</requires>
Collect those across every control and the migration state becomes obvious. Three distinct tiers:
Tier 1 — Knockout only. label, datepicker, icon, htmlViewer, timer, circle, rectangle. One CSS file, one JS file, a body of data-bind markup.
“Untouched” would be the obvious reading, and for most of them it is right. label is the exception, and it is worth a detour. Its version is 2.5.1 in both our 2023 and 2026 exports, but the templates are not byte-identical. In 2023 it loaded two scripts behind a feature gate:
<require type="javascript" src="js/label.js" excludeOnFeatureGate="controls.reactLabel" />
<require type="javascript" src="js/labelReactProxy.js" includeOnFeatureGate="controls.reactLabel" />
By 2026 it loads one:
<require type="javascript" src="js/label.js" />
A React label existed, gated behind controls.reactLabel, and was then removed. The control did not sit out the migration — it started one and reverted. Tier 1 here means “Knockout today”, not “never moved”.
Tier 2 — dual-path, mid-migration. button loads four files:
css/button.css
js/button.js ← the Knockout implementation
js/buttonReact.js ← the React implementation
js/buttonReactProxy.js ← the shim between them
It still carries 2,252 characters of Knockout markup and a React build and a proxy to switch between them. The text control is in the same state, with textReactProxy.js.
Tier 3 — React, done. attachments and combobox have essentially no markup left. The attachments template’s entire content block is:
<div class="appmagic-attachments">
</div>
Fifty-eight characters. Zero data-bind attributes. It loads attachmentsReact.js and nothing else — no Knockout implementation at all. The combobox is the same: an empty mount point plus comboboxReact.js, flyoutReact.js, and FabricForPublishedApps.js.
So the classic controls are not frozen in the way you would assume. They are being rewritten in React one at a time, in place, behind proxy shims, without version bumps or announcements. Two are finished, two are halfway, the rest have not started.
Where the migration actually ended: Fluent 9, in the old wrapper
The three tiers above are the state of the classic templates. A later export (August 2026) shows where this was heading, and it is not where the PCF detour suggested.
Four new templates appear — modernText, modernTextInput, modernCombobox, modernDatePicker — sitting in UsedTemplates, the classic list, wrapped in the same OpenAJAX widget XML, with jsClass="AppMagic.Controls.ModernTextControl".
Their contents are not classic at all:
| Template | data-bind count | Loads |
|---|---|---|
modernText | 0 | /openSource/modified/pcfplatformlibs/fluent_9_4_0.js, js/modernText.js |
modernTextInput | 0 | same Fluent bundle + js/modernTextInput.js |
modernCombobox | 0 | Fluent + PowerAppsControlsFluentIcons.js + js/modernCombobox.js |
modernDatePicker | 0 | Fluent + PowerAppsControlsFluentIcons.js + js/modernDatePicker.js |
Zero Knockout bindings. Every one loads Fluent UI React 9.4.0, vendored under the same /openSource/modified/ path as the date picker’s Pikaday fork.
So the endpoint is: the widget XML survives as a manifest format — property declarations, defaults, capability flags, the appMagic: metadata the platform needs — while rendering is entirely React. The <content> CDATA block that made the label control interesting is gone; there is nothing left to bind.
Read alongside the tier list, the whole arc is legible:
- Knockout widget with inline HTML (classic controls, still shipping)
- Dual-path, Knockout plus React behind a proxy shim (
button,text) - PCF-hosted, definition inlined into every control instance at 47–85 KB (
PowerApps_CoreControls_*) - Fluent 9 React in the native manifest, shared once at ~300 bytes (
modern*)
Step 3 was the expensive detour — it is what makes large apps enormous. Step 4 keeps the React rendering and throws away the per-instance packaging, which is why modernText costs roughly 240× less to store than the TextCanvas it replaces.
Microsoft’s control update guide, covering versions rolling out from February 2026, confirms the surface changes that come with step 4: the whole Font* prefix is dropped (FontColor → Color, FontSize → Size), BorderRadius splits into four corner properties, string values become typed enums (Align.Left, FontWeight.Bold), and TabIndex is removed.
That last one closes a loop opened earlier in this post.
TabIndex is gone, and that is a fix
Recall the image control’s confession — the platform cannot tell whether you wrote an OnSelect, so it uses TabIndex >= 0 as a heuristic to decide whether to render a <button> and expose it to assistive technology.
In all four gen-3 templates, TabIndex does not appear at all. Not renamed — zero occurrences. Tooltip survives only as localisation keys (##..._Tooltip##), never as a settable property. The classic label, by comparison, still contains six TabIndex references and eight Tooltip ones.
What replaced it is a single explicit property, present on all four:
<appMagic:includeProperty name="AccessibleLabel" />
That is the right fix. Accessibility semantics stop being inferred from an unrelated numeric property and become something you state. The role: TabIndex() >= 0 ? 'button' : 'presentation' line — and the “unfortunately” in the image control’s comment — describe a problem that has now been designed away.
It does mean any TabIndex or Tooltip you customised is dropped when a control updates. Microsoft’s guide lists “TabIndex removed” for Icon and “AcceptsFocus removed” for Button and Info Button. Worth checking before you press Update.
The date picker is a forked open-source library
The single best line in the whole file:
<require type="css" src="/openSource/modified/pikaday/pikaday.css" />
<require type="javascript" src="/openSource/modified/pikaday/pikaday.js" />
Power Apps’ classic date picker is Pikaday — a 2014-era MIT-licensed date picker — vendored under a path that says openSource/modified. Not wrapped. Forked and patched.
The combobox loads /js/FabricForPublishedApps.js — Office UI Fabric, Microsoft’s pre-Fluent design system, packaged specifically for published apps. And the gallery ships sample data as control resources:
data/imageGallery/ImageGallerySample.xlsx
data/textualGallery/TextualGallerySample.xlsx
data/CustomGallerySample.xlsx
data/CardStackGallerySample.xlsx
data/imageGallery/Image_Placeholder.svg
Those .xlsx files are what populate a new gallery with placeholder names and photos. They are Excel workbooks, shipped inside the control library.
The icon control’s 181 glyphs come from a single sprite: /ctrllib/icon/images/ctrllib-symbols.svg.
The capability flags
Each control declares what the framework should do on its behalf. The union across our sample:
| Flag | Meaning (inferred) | Controls |
|---|---|---|
isVersionFlexible | tolerates version drift | 14 of 14 |
contextualViewsEnabled | participates in contextual views | 13 |
autoDisabledViewState | framework renders disabled state | 13 |
autoBorders | framework draws borders | 12 |
autoPointerViewState | framework handles hover/press | 12 |
autoFocusedBorders | framework draws focus ring | 11 |
autoFill | framework applies Fill | 8 |
screenActiveAware | notified when its screen activates | 6 |
supportsSetFocus | valid target for SetFocus() | 5 — label, button, icon, image, text |
autoBorderRadius | framework rounds corners | 4 |
supportsNestedControls | can contain children | gallery, fluidGrid |
replicatesNestedControls | repeats children per record | gallery only |
replicationLimit | replication depth | gallery = 1, fluidGrid = 0 |
allowsPerCharacterFormatting | rich per-character formatting | label only |
managesNestedControlBounds | lays out its own children | fluidGrid only |
Two of these are directly actionable.
supportsSetFocus="true" appears on exactly five controls. That is the definitive answer to “why does SetFocus() not work on this?” — it is a per-control capability, declared in the template.
replicationLimit=1 on the gallery is the formal statement that galleries replicate one level deep. It is why nested galleries behave the way they do.
One honest disappointment
Every classic template carries a runtimeCost attribute:
runtimeCost="1"
We got excited. A per-control performance weight assigned by Microsoft would be a genuinely useful budgeting tool.
Every control in our sample is runtimeCost="1". Gallery, label, attachments, date picker — all 1. The attribute exists and the schema supports a hierarchy, but nothing in this sample reveals one. Either the values were never differentiated, or the differentiation lives on controls we do not have. Worth checking if you have a broader set of templates.
The unfinished bits
Microsoft’s placeholder text also shipped. Every classic control carries this:
<license type="text/html"><![CDATA[<p>TODO: Need license text here.</p>]]></license>
<description><![CDATA[LABEL
Control description here.]]></description>
“TODO: Need license text here.” “Control description here.” In production, in every canvas app in the world — and the 2023 export has the identical placeholder, so it has been that way for at least three years.
Why any of this matters
If you build tooling for canvas apps, this is your specification. The templates tell you every valid property, its type, its default, its category, its localisation key, and — uniquely — its actual runtime behaviour. That is strictly more information than the documentation carries.
If you build apps, three concrete takeaways: set Role on labels because it emits real heading tags; use [data-control-part] for UI automation; and stop guessing about SetFocus(), because the answer is a boolean in the template.
Methodology
Sample: 14 classic control templates from two production .msapp files (exports dated 2023-02-14 and 2026-02-16). Templates are XML strings inside References/Templates.json.
unzip -q app.msapp -d app/
python3 - <<'EOF'
import json, re
for t in json.load(open('app/References/Templates.json'))['UsedTemplates']:
x = t['Template']
m = re.search(r'<widget([^>]*)>', x)
if not m: continue
a = dict(re.findall(r'([\w:]+)="([^"]*)"', m.group(1)))
print(f"{t['Name']:<14} {a.get('jsClass'):<44} data-bind x{len(re.findall('data-bind', x))}")
for ty, src in re.findall(r'<require type="(\w+)" src="([^"]+)"', x):
print(f" {ty}: {src}")
EOF
An .msapp only embeds templates the app uses, so this covers 14 of the classic library, not all of it. Quoted markup, file paths, and attribute values are verbatim; capability-flag meanings are inferred from names and observed behaviour, not from official documentation.