Forge Custom UI apps run inside iframes. That iframe has no knowledge of the host Confluence theme unless you explicitly ask for it. If you skip this step, your app renders as a bright white rectangle inside someone's dark Confluence instance. It looks broken, even if it works perfectly.
The Forge Bridge API provides a mechanism to detect the host theme and react to changes. But "provides a mechanism" does not mean you call one function and walk away. The mechanism gives you a data-color-mode attribute on the <html> element. What you do with that attribute -- how you structure your CSS, how you handle transitions, how you make every component respond correctly in both modes -- is entirely your problem.
This tutorial covers the full theme implementation we built for Attachment Locker. It starts with the detection layer, moves through the CSS custom property system, and ends with how individual components consume those properties. Between these three layers, you get an app that looks native in both light and dark Confluence, switches instantly when the user changes their theme, and requires zero manual theme management from the React layer.
The Detection Layer
Before any CSS does anything useful, you need to know which theme the host is running. Forge provides this through the view.theme.enable() method from @forge/bridge. When you call it, the Forge runtime fetches the current theme from Confluence, sets a data-color-mode attribute on the document's <html> element, and continues to update that attribute whenever the host theme changes.
The important detail is that this is asynchronous. If your components render before enable() resolves, they render in whatever the browser's default state is -- which means a flash of unstyled content. Every component needs to wait for theme detection before showing itself.
This lives at src/frontend/utils/theme.js. A few things worth noting.
The initializationPromise guard prevents multiple concurrent calls from triggering view.theme.enable() more than once. In Attachment Locker, five separate modules -- the button panel, the full modal, the admin page, the space admin page, and the page banner -- all call enableThemeDetection() on mount. Without the guard, you get five parallel calls to the Forge Bridge, and the behaviour is undefined.
The catch block sets themeInitialized = true even on failure. This is deliberate. If theme detection fails -- which can happen during local development or if the Forge Bridge is not fully loaded -- the app should still render. It will render in light mode (the CSS default), which is better than rendering nothing.
getCurrentThemeMode() reads the attribute directly from the DOM. isDarkModeActive() is a convenience wrapper. addThemeChangeListener() sets up a MutationObserver that watches for changes to data-color-mode and fires a callback. The observer is configured to watch only that specific attribute, which keeps it lightweight.
The listener returns a cleanup function. If you use it in a React useEffect, call the cleanup in the return:
In practice, Attachment Locker does not use the listener. The CSS variable system handles everything reactively through selectors, so no JavaScript runs on theme change. The listener exists for edge cases where you need to know the current mode in JavaScript -- for example, generating an SVG with a hardcoded colour, or choosing between two different icon sets.
The CSS Variable System
This is where the real work happens. The entire visual language of the app is expressed as CSS custom properties, defined in a single file at src/frontend/styles/theme.css. Light mode values are set on :root. Dark mode overrides are set on html[data-color-mode="dark"]. When Forge updates the data-color-mode attribute, every CSS variable changes instantly, and every element that references those variables re-renders with the new values.
No JavaScript. No React state. No context providers. Just CSS selectors doing what CSS selectors do.
Variable Naming Convention
Every variable uses the --al- prefix (for Attachment Locker) followed by a category and a semantic name. The categories are:
--al-bg-* -- Background colours
--al-text-* -- Text colours
--al-border-* -- Border colours
--al-surface-* -- Surface/elevation colours
--al-status-* -- Status colours (success, warning, info, neutral)
--al-interactive-* -- Button and link colours with hover/active states
--al-scrollbar-* -- Scrollbar track and thumb colours
--al-shadow-* -- Box shadow colours
--al-gradient-* -- Header and footer gradients
The prefix matters. Forge Custom UI runs in an iframe, so CSS does not leak. But if you ever embed third-party styles or share CSS between modules, namespacing prevents collisions. It also makes it trivial to search the codebase for every theme reference.
The light mode colours are based on Atlassian's design system palette. #172b4d is Atlassian's standard dark text colour. #fafbfc and #f4f5f7 are their standard background greys. The dark mode colours follow Confluence's actual dark theme -- #1d2125 as the primary background, #b6c2cf as the primary text. These are not arbitrary. If you pick your own dark mode palette, the app will look foreign inside Confluence even if it is technically readable.
Notice --al-text-inverse flips direction. In light mode it is white (for text on coloured buttons). In dark mode it is the dark background colour (for the same purpose). This is not intuitive, and if you forget it, your button text becomes invisible in one mode.
Status Colours
Status colours need three variables each: the foreground colour, the background colour, and the border colour. In dark mode, all three shift. Simply darkening the light mode values does not work -- you need to pick specific dark-friendly versions that maintain sufficient contrast.
The dark mode backgrounds are deeply saturated versions of the status colour, not transparent overlays. Transparent overlays interact unpredictably with stacked elements. Opaque colours behave consistently regardless of what sits behind them.
Interactive Colours
Buttons need three states: default, hover, and active. That is nine variables for three button variants (primary, success, danger):
The dark mode primary colour (#579dff) is Atlassian's standard dark-mode link blue. The light mode primary (#4a90e2) is the standard light-mode blue. Using the right blues in each mode is what makes the app look like it belongs in Confluence rather than being a foreign panel bolted onto the page.
Shadows and Gradients
Shadows need to be heavier in dark mode because the contrast between elements is lower:
If you keep light-mode shadows in dark mode, elevated elements look flat because there is not enough contrast between the shadow and the surrounding dark background. Doubling or tripling the opacity restores the visual hierarchy.
The Full Variable Count
The complete theme.css defines around 90 custom properties across both modes. That sounds like a lot, and it is -- but each one exists because a specific component needed it. We did not design the system top-down and then apply it. We built components, discovered that they needed a specific colour, added a variable, and gave it a dark-mode counterpart. The result is a system that covers everything the app actually uses, with no unused tokens.
Global Styles
Below the variable definitions, theme.css sets global styles that apply universally:
The font stack matches Confluence's system font stack. If your Forge app uses a different font family, it creates a visual seam between the host and the iframe that users notice even if they cannot articulate what is wrong.
The overflow: hidden on body is specific to Forge Custom UI. The iframe handles scrolling at the container level, so the body should not scroll independently. Without this, you get double scrollbars in some browsers.
This applies a 0.3-second transition to every element for the four properties most affected by theme changes. When the user switches themes, every colour in the app fades smoothly instead of snapping. The transition duration matches Confluence's own theme transition speed.
The universal selector has a performance cost. On a page with a hundred elements, that is a hundred transition listeners. In Attachment Locker, the largest view (the space admin table with many rows) still transitions smoothly because the properties being transitioned are compositable -- the browser can handle them on the GPU. If you were transitioning width or height universally, this would be a problem. For colour properties, it is not.
Focus styles use --al-border-focus, which is #4a90e2 in light mode and #579dff in dark mode. Both are visible against their respective backgrounds. The outline-offset prevents the focus ring from overlapping the element's border, which looks messy with rounded corners.
Scrollbar styling only works in WebKit-based browsers (Chrome, Edge, Safari). Firefox uses scrollbar-color and scrollbar-width, which we do not set here because the Forge iframe targets Chromium in practice. The narrow 6px scrollbar matches the slim scrollbar style that Confluence uses in its own panels.
Each utility class includes a fallback value. If the CSS variable is somehow undefined -- which should never happen in production but can happen during development when the stylesheet is not loaded -- the fallback ensures the element is still visible.
Component-Level CSS
Each UI module has its own CSS file that builds on the variables from theme.css. The modal, button panel, admin page, space admin page, and page banner each import the theme variables and define their own component-specific styles.
Modal Styles
The modal is the most complex component. Its CSS defines the full modal chrome -- header gradient, tab navigation, table styling, status badges, and action buttons:
The tab navigation uses --al-interactive-primary for the active state, which automatically resolves to the correct blue in each mode. The inactive tabs use --al-text-secondary, which is a mid-grey in both modes. The active tab gets a background: var(--al-bg-primary) to visually lift it above the tab bar -- in light mode this is white, in dark mode this is the dark background. The effect is the same: the active tab looks like it connects to the content below.
Each badge uses all three status variables (colour, background, border). This three-part approach is what makes status badges readable in both modes. A badge that only sets background and text colour looks washed out in dark mode because the boundary between badge and surrounding content is unclear. The border provides that boundary.
The hover uses --al-bg-secondary, which is a slightly different shade from the primary background in both modes. Combined with a subtle shadow and a 1px lift, this creates an elevation effect that reads correctly whether the table sits on a white or dark surface.
How Components Consume Theme Values
Attachment Locker uses two approaches: CSS classes from the component stylesheets, and inline styles that reference CSS variables directly.
Approach 1: CSS Classes
The modal, admin, and space admin views use CSS classes for structural styling:
This works because var() is valid in inline styles. The browser resolves the variable at render time, not at parse time, so it picks up the current value from the cascade. When the theme changes and the variable values update, the inline styles update too -- no re-render needed.
The trade-off is readability. Inline styles with CSS variables are harder to scan than class names. In Attachment Locker, we use classes for the structured views (modal, admin pages) and inline styles for the compact views (button panel, banner) where the layout is simpler and the styling is more tightly coupled to the component logic.
Hover States in Inline Styles
CSS variables work in inline styles, but CSS pseudo-selectors do not. You cannot write style={{ ":hover": { ... } }}. The workaround is onMouseEnter and onMouseLeave event handlers:
This is verbose. For a component with many interactive elements, CSS classes are cleaner. But for a single button in a compact panel, the inline approach keeps everything in one place without creating a CSS file for a one-off component.
Initialisation Pattern
Every React component in Attachment Locker follows the same initialisation pattern:
enableThemeDetection() is always the first call. It must complete before the component renders its themed content. In practice, this means wrapping the component's initial state in a loading state and only showing the real UI after the useEffect completes.
The page banner has a slight variation -- it calls enableThemeDetection() synchronously at module scope before the React tree mounts:
This works because enableThemeDetection() returns a promise but does not block rendering. The theme will resolve after the first render, and the CSS transitions will smoothly update the colours. For a banner that needs to show immediately and might be dismissed quickly, this avoids the loading state entirely.
Each component CSS file duplicates the :root and html[data-color-mode="dark"] variable definitions from theme.css. This is intentional. Forge Custom UI bundles each resource independently -- the button panel, modal, admin page, space admin page, and page banner are separate HTML files served from separate directories. They do not share a common stylesheet. Each one needs its own copy of the variable definitions.
The duplication sounds like a maintenance problem, and it is. If you change a colour value, you need to update it in six places. The alternative is extracting the variables into a shared file and importing it at build time, which requires configuring a CSS bundler. Attachment Locker uses @forge/bundler (the default Forge webpack setup), so adding a shared CSS import would mean customising the webpack config. For 90 variables that rarely change, the duplication is the simpler trade-off.
What This System Does Not Do
It does not use a React context or provider. There is no <ThemeProvider>. No useTheme() hook that returns the current mode. No JavaScript runs when the theme changes. The CSS cascade handles everything.
It does not support custom themes beyond light and dark. Confluence only offers two modes, and the data-color-mode attribute only has two values. If Confluence adds a third theme in the future, you would add a third set of variable overrides under a new attribute selector.
It does not use Atlassian Design System tokens. The Atlassian Design System provides its own set of CSS variables (--ds-*), but those are not available inside Forge Custom UI iframes. The iframe is a sandboxed environment with its own DOM. You need to define your own tokens and match them visually to the host.
It does not handle system-level prefers-color-scheme media queries. The theme is determined by Confluence, not by the operating system. If a user has their OS set to dark mode but Confluence set to light mode, the app follows Confluence. This is correct -- the app lives inside Confluence, not alongside it.
Patterns Worth Noting
Fallback Values Everywhere
Every utility class and inline style that uses a CSS variable includes a fallback:
If the stylesheet fails to load, the fallback ensures the element is not invisible. The fallback values are always the light-mode values, because light mode is the safe default -- it is readable on a white background, which is the browser's default.
Every button in the app lifts 1px on hover and returns on click. This is a global style, not per-component. The :not(:disabled) guard prevents disabled buttons from responding to hover, which would suggest interactivity where there is none.
The Loading Spinner
1.loading-spinner{2width:40px;3height:40px;4border:3px solid var(--al-border-primary);5border-top:3px solid var(--al-interactive-primary);6border-radius:50%;7animation: spin 1s linear infinite;8}
The spinner is built entirely from CSS -- no SVG, no image, no library. It uses --al-border-primary for the track and --al-interactive-primary for the spinning segment. Both colours update on theme change, so the spinner looks correct in both modes without any special handling.
Wrapping Up
A theme system for a Forge app does not need to be complicated. It does need to be thorough. Every colour that can appear on screen needs a variable. Every variable needs a dark-mode counterpart. And every dark-mode counterpart needs to be chosen deliberately, not generated by darkening a hex code.
Call view.theme.enable() once, early, and guard it against duplicate calls. Define your variables on :root for light mode and on html[data-color-mode="dark"] for dark mode. Use the --al- prefix (or your own app prefix) consistently. Add fallback values. Apply smooth transitions to colour properties. Match Confluence's own colour palette as closely as you can.
The result is an app that does not look like an app. It looks like part of Confluence. Users switch between light and dark mode and your panel, your modal, your admin page -- they all follow. No flash. No stale colours. No "oh, the extension does not support dark mode."
That is the bar. It is achievable with CSS variables and one Forge Bridge call. Everything else is craft.