CSS in Power Apps HTML text: build a safer scoped PCF component
At a Glance
- Target Audience
- Power Apps makers and professional developers who need reusable CSS for trusted HTML content in canvas apps
- Problem Solved
- Shows how to move beyond inline HTML text styles without injecting arbitrary CSS into the host page or pretending innerHTML sanitizes user content.
- Use Case
- Build, package, deploy and maintain a scoped PCF renderer for trusted maker-authored HTML in a canvas app.
The Power Apps HTML text control is useful until the markup grows.
A few inline styles become repeated strings. Lists lose their browser defaults. Reusable classes, pseudo-selectors and responsive rules become awkward or impossible to manage inside a Power Fx formula.
A Power Apps component framework (PCF) control can solve that problem—but the obvious implementation creates two more.
Do not inject a raw stylesheet into the page <head>, and do not render untrusted HTML with innerHTML as if it were safe.
Microsoft says code-component CSS must be scoped to the component and that accessing host-app DOM outside the component boundary is unsupported. This guide builds within that boundary.
The short answer
Use the standard HTML text control when:
- the content is small;
- inline styles are manageable;
- you do not need reusable CSS selectors;
- adding a custom-code dependency would be excessive.
Use a PCF component when you need a reviewed, reusable renderer with:
- packaged and scoped CSS;
- classes and pseudo-selectors;
- responsive rules;
- controlled theme inputs;
- a proper solution and version lifecycle.
This is a professional-development route, not a trick for bypassing the standard control.
Why the old version of this tutorial was unsafe
The previous sample accepted arbitrary HTML and CSS strings, then did this:
document.getElementsByTagName("head")[0].appendChild(styleElement);
container.innerHTML = html;
It looked convenient. It also crossed the code-component boundary, allowed global selectors to affect the host app, omitted cleanup and gave no rule for trusted versus untrusted HTML.
Microsoft’s PCF best-practices guide is explicit:
- host DOM outside the component boundary is unsupported;
- CSS should be scoped to the generated component container;
- resources created outside the supplied container must be cleaned up in
destroy(); - components need accessibility, responsive and performance testing.
The repair is not to add one more warning to the old code. It is to change the design.
The design used here
The component will have two inputs:
HtmlContent: markup authored and controlled by the app maker;AccentColor: one bounded theme value, rather than an arbitrary stylesheet.
The CSS is stored in the component project and declared in its manifest. Every selector is scoped beneath the PCF container class.
The TypeScript renders only inside the container supplied by the framework.
Security boundary
This sample is for trusted, maker-authored HTML.
Do not bind HtmlContent directly to:
- Forms responses;
- list fields editable by ordinary users;
- email bodies;
- external API output;
- AI-generated HTML;
- copied web content.
innerHTML parses markup. It is not a sanitizer.
If untrusted content must be displayed, either render it as text with textContent, build an allowlisted element tree with DOM APIs, or use a well-maintained sanitizer that your security team has configured and tested for the exact allowed markup. A hand-written regular expression is not an HTML sanitizer.
Prerequisites
You need:
- a Power Apps licence appropriate to the target environment;
- permission to build and import a solution;
- Microsoft Power Platform CLI;
- a supported Node.js release for the current PCF tooling;
- .NET build tools for solution packaging;
- a Dataverse environment for the solution;
- an administrator who can enable PCF for canvas apps if it is not already enabled.
Microsoft’s canvas code-component guide also warns that code components opened in Power Apps Studio can access tokens and data. Administrators should review the source and only import trusted solutions.
1. Create the PCF project
Open a terminal in an empty working folder and run:
pac pcf init --namespace Collab365 --name ScopedHtml --template field --run-npm-install
The command creates the manifest, TypeScript entry point, generated types and build configuration.
The current Microsoft walkthrough is Create your first component. Follow its tool prerequisites if the command is not available.
2. Define controlled inputs and packaged CSS
Open ControlManifest.Input.xml.
Keep the generated control element, then define these properties and resources inside it:
<property
name="HtmlContent"
display-name-key="HTML content"
description-key="Trusted HTML authored by the app maker"
of-type="Multiple"
usage="input"
required="false" />
<property
name="AccentColor"
display-name-key="Accent colour"
description-key="Six-digit hexadecimal colour, for example #0F6CBD"
of-type="SingleLine.Text"
usage="input"
required="false" />
<resources>
<code path="index.ts" order="1" />
<css path="css/ScopedHtml.css" order="1" />
</resources>
The important difference from the old sample is that CSS is a declared component resource. Microsoft documents the css manifest element for canvas and model-driven apps.
After changing the manifest, run a build so the generated input types are refreshed.
3. Add scoped CSS
Create ScopedHtml/css/ScopedHtml.css.
.Collab365\.ScopedHtml .c365-html {
--c365-accent: #0f6cbd;
box-sizing: border-box;
color: #242424;
font: 400 1rem/1.5 "Segoe UI", sans-serif;
max-width: 100%;
}
.Collab365\.ScopedHtml .c365-html *,
.Collab365\.ScopedHtml .c365-html *::before,
.Collab365\.ScopedHtml .c365-html *::after {
box-sizing: inherit;
}
.Collab365\.ScopedHtml .c365-html h2 {
color: var(--c365-accent);
font-size: 1.35rem;
margin: 1.25rem 0 0.5rem;
}
.Collab365\.ScopedHtml .c365-html .callout {
border-inline-start: 0.25rem solid var(--c365-accent);
background: #f5f5f5;
padding: 0.75rem 1rem;
}
.Collab365\.ScopedHtml .c365-html .striped > :nth-child(even) {
background: #fafafa;
}
.Collab365\.ScopedHtml .c365-html a {
color: var(--c365-accent);
text-decoration: underline;
}
.Collab365\.ScopedHtml .c365-html a:focus-visible {
outline: 0.125rem solid var(--c365-accent);
outline-offset: 0.125rem;
}
@media (max-width: 600px) {
.Collab365\.ScopedHtml .c365-html {
font-size: 0.95rem;
}
}
Collab365.ScopedHtml is the namespace and component name from the manifest. The backslash escapes the dot in the generated container class.
The rules cannot style unrelated Power Apps elements because every selector begins inside that component boundary.
4. Implement the component lifecycle
Replace the generated implementation in index.ts with the following shape:
import { IInputs, IOutputs } from "./generated/ManifestTypes";
export class ScopedHtml
implements ComponentFramework.StandardControl<IInputs, IOutputs>
{
private root!: HTMLDivElement;
private lastHtml = "";
private lastAccent = "";
public init(
_context: ComponentFramework.Context<IInputs>,
_notifyOutputChanged: () => void,
_state: ComponentFramework.Dictionary,
container: HTMLDivElement
): void {
this.root = document.createElement("div");
this.root.className = "c365-html";
container.appendChild(this.root);
}
public updateView(
context: ComponentFramework.Context<IInputs>
): void {
const html = context.parameters.HtmlContent.raw ?? "";
const requestedAccent = context.parameters.AccentColor.raw ?? "";
const accent = /^#[0-9a-f]{6}$/i.test(requestedAccent)
? requestedAccent
: "#0f6cbd";
if (accent !== this.lastAccent) {
this.root.style.setProperty("--c365-accent", accent);
this.lastAccent = accent;
}
if (html !== this.lastHtml) {
// Trusted maker-authored HTML only. This does not sanitize input.
this.root.innerHTML = html;
this.lastHtml = html;
}
}
public getOutputs(): IOutputs {
return {};
}
public destroy(): void {
this.root.replaceChildren();
}
}
Why these details matter:
- The framework’s
containeris the only host surface the control changes. - The stylesheet is packaged, not appended to the document head.
- The accent input accepts one validated six-digit hexadecimal value, not executable CSS.
updateViewavoids replacing the DOM when the input has not changed.destroy()clears the component’s content.- Null inputs are handled because PCF can call
updateViewbefore every bound value is ready.
The method signatures follow the current Microsoft lifecycle documented through Microsoft Learn’s PCF tutorial.
5. Build and test locally
Run:
npm run build
npm start watch
The first command validates the manifest, generates types and builds the component. The second starts the PCF test harness.
Test at least:
- empty input;
- headings, paragraphs, lists and links;
.calloutand.stripedclasses;- a valid and invalid accent colour;
- long content;
- narrow and wide containers;
- keyboard navigation and visible focus;
- repeated
updateViewcalls; - component removal and re-addition.
The harness does not prove that the component works in every Power Apps host. Test again in a development environment and the published canvas app.
6. Package it in a solution
Do not deploy a development build as the production component.
Follow Microsoft’s solution-packaging steps:
- Create a solution project with your organisation’s publisher and prefix.
- Add a reference to the PCF project.
- Build the solution in Release configuration.
- Import the solution into a non-production Dataverse environment.
- Run Solution Checker and your organisation’s source/security review.
- Promote the same reviewed solution through the normal deployment route.
Increment the component version when releasing an update. Canvas apps keep a copy of a code component, so after a new version is deployed the app maker must open the app, accept the component update, save and publish. Microsoft describes that boundary in PCF application lifecycle management.
7. Add it to a canvas app
If the Code tab is missing, ask an administrator to check Power Apps component framework for canvas apps under the environment’s product features.
After the solution is imported:
- Open the canvas app in Power Apps Studio.
- Select Add and then Get more components.
- Open the Code tab.
- Select
ScopedHtmland import it. - Add the component to the screen.
- Set
HtmlContentto trusted markup. - Set
AccentColorto a value such as"#0F6CBD".
The current route is documented in Add components to a canvas app. The older Insert → Custom → Import component route is deprecated.
Example Power Fx input
For fixed, maker-authored content:
"<section>
<h2>Quarterly review</h2>
<p class='callout'>Figures are provisional until Finance signs them off.</p>
<div class='striped'>
<p>Revenue</p>
<p>Margin</p>
<p>Forecast</p>
</div>
</section>"
If a value from a record must be included, encode it as text before inserting it into markup. Do not concatenate an untrusted field into an HTML attribute or tag.
For data-driven layouts, it is often safer to give the component structured properties or a reviewed JSON schema and let TypeScript create the elements. That keeps data separate from markup.
Accessibility checks
A custom renderer does not become accessible because it uses HTML.
Microsoft notes that the standard HTML text control does not automatically define ARIA mappings and has keyboard limitations. A PCF component gives you more control, but you must use it.
Check:
- semantic heading order;
- meaningful link text;
- keyboard focus order;
- visible focus styles;
- colour contrast;
- zoom and reflow;
- screen-reader output;
- reduced-motion requirements if animation is added;
- touch target size.
Do not turn a <div> into a fake button. Use a real <button> and implement the event and output contract if interaction is required.
Performance and support checks
Before production:
- measure the screen with the component present, not just the harness;
- avoid several different heavy PCF components on one screen;
- test current supported browsers and required mobile clients;
- document the solution version and owning team;
- keep the source in version control;
- use a production build;
- record how an app maker accepts future component updates;
- provide a rollback solution or previous managed version.
Review the PCF limitations before adding APIs, browser storage or custom authentication. Canvas code components do not expose every Dataverse-dependent API, browser storage is not a secure store and custom authentication is unsupported.
Frequently asked questions
Can the standard Power Apps HTML text control use a stylesheet?
It supports HTML formatting and inline styles, but it is not a general web page with an external stylesheet. Use it for modest display requirements and move to a reviewed PCF component only when the added capability justifies custom code.
Can a PCF component add CSS to the page head?
Do not use that as the supported design. Microsoft says access to host-app DOM outside the component boundary is unsupported and global CSS can break the surrounding app. Package scoped CSS as a component resource.
Is innerHTML safe with a SharePoint or Forms value?
No. innerHTML parses markup and does not sanitize the value. This sample permits only trusted maker-authored HTML. Render untrusted values as text or use a security-reviewed sanitizer and allowlist.
Why use a colour property instead of a CSS string?
A bounded property gives app makers the required theme control without allowing arbitrary selectors or declarations. The component validates the value and maps it to a CSS custom property.
How do canvas apps receive an updated PCF version?
Deploy the versioned component through a solution. The app maker then opens the app, accepts the code-component update, saves and republishes the app.
Building Power Apps components that other people must support? Join Power Apps Builders for current, human-audited implementation guidance.
