
Theme flipping in Canvas: simple controls for complex UX
Dark mode and light mode in Canvas compositions. How theme context works and what breaks if you are not careful.
A university asked me to implement theme switching (light mode, dark mode) in a Canvas-built site. They wanted to let users choose, respect system preference, and have that preference apply consistently across all their Canvas compositions. It sounded straightforward.
I spent an afternoon discovering all the ways it is not.
How it works simply
The theme-flip module handles client-side switching — the user clicks a toggle, localStorage gets updated, a data attribute appears on the document root. CSS responds to that attribute. Done.
Canvas compositions respond to CSS the same way any HTML does. If the canvas contains a card with CSS that targets [data-theme="dark"], the card will render differently in dark mode. The composition layer does not care where the theme CSS comes from. It just renders.
So the naive implementation works: drop the theme-flip module into a Canvas site, write CSS that handles both themes, done. The compositions automatically respect the user’s theme choice.
Where it gets interesting
The complexity arrives when you want theme awareness to flow through Canvas context. A card should not just have CSS that responds to the root theme. It should know what theme it is in and be able to adjust its composition based on that.
Canvas 1.2 added context support. A component can declare that it needs theme context:
requires: context: theme: {} theme-preference: {}The theme-flip module can provide that context to Canvas:
public function getContextProviders() { return [ 'theme' => [ 'value' => $this->getCurrentTheme(), 'description' => 'Current theme (light or dark)', ], ];}Now a card’s Twig template knows the theme:
<article class="card" data-theme="{{ context.theme }}"> {% if context.theme == 'dark' %} <img src="{{ image.src }}" alt="{{ image.alt }}" loading="lazy"> {% else %} <picture> <source media="(prefers-color-scheme: dark)" srcset="{{ image.dark_src }}"> <img src="{{ image.src }}" alt="{{ image.alt }}" loading="lazy"> </picture> {% endif %}</article>The card can now render entirely differently based on theme. Not just CSS. Actual markup changes.
What I did wrong the first time
I initially made the theme context reactive — every time the user toggled the theme, the canvas would re-render with the new context. This worked, but it was janky. The DOM was being rebuilt, focus was lost, scroll position jumped.
The better approach is to keep the DOM stable and use CSS for theme switching. Only use Canvas theme context for things that genuinely need markup changes — an image that has a light version and a dark version, for instance.
The rule I settled on: CSS for styling, context for structure. If you can solve it with [data-theme="dark"] .card { color: #fff; }, do that. Do not make Canvas re-render.
What broke
I ran into a bug where Canvas cached the context value on initial composition and did not update it when the user toggled the theme. The context was stale. The toggle worked in the page’s CSS but not in the Canvas compositions.
The fix was to make the context value dynamic:
public function getContextValue($key) { if ($key === 'theme') { return [ 'current' => $this->getStoredTheme(), 'preference' => $this->getSystemPreference(), 'timestamp' => time(), ]; }}Including the timestamp forces Canvas to see the value as new. It is a hack. I am watching to see if Canvas 1.3 has a cleaner pattern for reactive context.
What still feels wrong
The disconnect is that theme-flip manages theme state in localStorage, but Canvas context needs to know about that state. They are not natively integrated. A Canvas-first approach would have theme switching built in as a Canvas feature, not as a separate module providing context.
I think the right long-term solution is for Canvas to have native theme support. An editor could click a setting: “this canvas should provide theme context.” The user’s theme choice automatically becomes available to all components. No module glue. No context provider configuration.
Until then, the pattern of theme-flip plus Canvas context works. It is just not elegant.
For a university site, this is good enough. Users get theme switching, compositions respect it, CSS and context work together. The implementation is not as clean as I would like, but it delivers the feature without requiring custom code.