Skip to content

Styling

This page explains the layers of styling within the app code and attempts to cover any edge cases. It is intended to be useful for answering two questions: where a style rule belongs, and why a rule that looks correct isn't winning.

For themes specifically — how one is built, parent themes, adding a child theme — see Themes.

The layers

Layer Where Use it for
Component styles *.component.scss next to the component How a component looks by default, in every theme
Component global styles *.component.global.scss, registered in src/theme/_components.scss Parts of a component that its own styles cannot reach (see below)
App-wide styles src/theme/variables.scss, _typography.scss, _layout.scss Base variable values, and styling of plain HTML that components render
Themes src/theme/themes/*/_index.scss (variables), then _overrides.scss (rules) Anything that differs between deployments
Deployment styles src/theme/deployment/ Rules that apply to every deployment, including hacks that don't belong to one component
Platform overrides src/theme/overrides.ios.scss Differences between iOS and Android

src/global.scss pulls these together, and variables.scss is where every theme is included, so theme rules land in the cascade at that point.

Component styles are the exception: Angular injects them at runtime rather than through global.scss, so you cannot reason about component styles versus global styles by source order. Specificity decides, which is the subject of the next section.

Prefer the highest layer that can express the change. A component style is one rule that every theme benefits from; a theme override is a copy that has to be maintained separately and breaks quietly when the component changes.

Why a theme override may lose to a component

Component styles are compiled with Angular's emulated encapsulation, which rewrites their selectors to include an attribute unique to that component:

// audio.component.scss, as authored
.container-player .btn-play ion-icon { … }

// as compiled (the id is generated per component)
.container-player .btn-play ion-icon[_ngcontent-ng-c12345] { … }

A theme override contributes one attribute ([data-theme="…"]) plus a few element names. A component selector contributes a class for each level of nesting, plus that encapsulation attribute. Specificity counts classes and attributes together and ranks them above element names, so a deeply nested component rule frequently outranks a theme override:

Selector Specificity
[data-theme="x"] body plh-audio ion-icon 0,1,3
.container-player .btn-play ion-icon[_ngcontent-…] 0,3,1

This is why _overrides.scss files carry so much !important. It is usually not carelessness — it is what made the rule apply at all. When you move a rule out of a theme override and into a component, check what was actually winning before, rather than assuming the override was in effect.

A few components set ViewEncapsulation.None (plh-template-component among them), so their styles are global and get no encapsulation attribute. Rules in those files apply everywhere and need to be written defensively.

Custom properties resolve where they are declared

A var() inside a custom property is substituted on the element where that property is declared, not where it is eventually used:

:root {
  --paragraph-spacing: 0.75em;
  // Resolves here, against :root — a theme that sets --paragraph-spacing will not change it
  --text-outer-margin: var(--paragraph-spacing);
}

So a default written at :root that refers to another variable captures the base value and ignores whatever a theme sets. Defaults that need to follow a theme's own values are declared per theme instead, in $component-variable-defaults in themes/utils/generate-theme.scss. Fixed values belong in variables.scss.

Note that lengths are not affected by this. A custom property holds unresolved tokens, so --text-outer-margin: 0.75em inherited by a child still resolves em against that child's own font size.

Never declare theme variables below the theme scope

Theme variables are declared on body (see themes/utils/theme-selectors.scss). Anything that re-declares one of them on an element inside body wins for everything within it, silently disabling that variable for every theme.

If a value needs to apply everywhere, give it a base value in variables.scss. Declaring it lower in the tree does not make it a stronger default, it makes it an unconditional one.

Content a component cannot reach

Some markup is outside the reach of a component's own encapsulated styles. That is what *.component.global.scss files are for; each is registered in src/theme/_components.scss.

Markdown rendered through [innerHTML]. The generated elements carry no encapsulation attribute, so component styles never match them. The text bubble styles its markdown this way.

Ionic components injected elsewhere in the DOM. Modals are created by ModalController and attached outside the component that opened them, so their styling has to be global. Nav-stacks and pop-ups both work this way.

Modals get a second copy of every theme variable

Each theme emits its variables twice — on [data-theme] body, and again on [data-theme] ion-modal::part(content). The second exists because a modal's content is inside a shadow root, and the part is the only handle on it.

Two consequences:

  • The part declaration sits deeper than anything on body, so inside a modal it wins. If the two ever disagree, page content and modal content render differently.
  • You cannot write descendant selectors after ::part(). Rules nested under it are emitted but never match, so only the variable declarations take effect.

"Modal" here means everything Ionic presents as one: nav-stacks, template pop-ups, the combo box modal and search, the audio transcript, the reactive dropdown. A nav-stack holds a whole template, so a change that looks confined to a small overlay can reach a main app flow.

Page rows and nested content

A template's rows are rendered by plh-template-container, which is the only place that gives a row class="row". Components that render child rows — display groups, text bubbles, accordions — render them as plain plh-template-component.

Row spacing follows the same distinction. template-component.scss applies --row-margin-top only to direct children of a container:

plh-template-container > plh-template-component:not([data-hidden="true"])
  ~ plh-template-component:not([data-hidden="true"]) {
  margin-top: var(--row-margin-top);
}

Rows nested inside a component get no row spacing, and are spaced by whatever that component provides.

So a rule meant for page-level content has to say so, by targeting plh-template-component.row > …. A rule written against the component alone also applies to every copy of it nested inside a container, where the container's own padding is usually already handling the spacing — and where the result depends on what the container happens to hold.

Checking a change

Theme CSS is generated at build time, so a change can be checked by compiling it, and a refactor meant to change nothing can be verified by comparing one theme's declarations before and after. Themes describes both.

Neither catches a change in layout. A rule moved between layers can leave every declaration identical and still render differently, because which rule applies depends on specificity, and because a rule that reaches inside a container has an effect that depends on what the container holds. Those changes still need checking by eye, in each theme they affect.