Theming
Most extensions are happy with how the Panel already looks. You slot a card onto a page, register a route, and you inherit the surrounding chrome for free (see Mounting UI). But sometimes you need to change the look itself: recolor the whole Panel to match your brand, restyle every Button at once, or replace a stock component with your own everywhere it shows up.
There are three layers for this, from broadest to most surgical:
- The Mantine theme -
initializeMantineTheme()returns a theme override that gets merged across all extensions and applied globally. This is the one you want for colors, fonts, radii, default component props - anything Mantine's theming already models. - CSS variables and Tailwind tokens - ship your own
app.cssto override the--mantine-color-*variables and the Tailwind tokens the Panel defines. This is for the stuff that lives below the Mantine theme object, like chart colors. - Hookable components - every element in the Panel's component library is hookable. You can intercept its props, wrap its rendered output, or replace its implementation entirely, and your change applies everywhere that component is used (core pages included).
Rule of thumb: pick the broadest layer that does the job. Drop down to hookable components only when the theme and CSS layers can't get you there.
The Mantine Theme
The Panel runs on Mantine, and Mantine's theme object is where colors, the primary color, fonts, spacing, radii, and per-component default props all live. Your extension chips in by overriding initializeMantineTheme() on your Extension class:
import { Extension, ExtensionContext } from 'shared';
import type { MantineThemeOverride } from '@mantine/core';
class MyExtension extends Extension {
public initializeMantineTheme(ctx: ExtensionContext): MantineThemeOverride {
return {
primaryColor: 'grape',
defaultRadius: 'md',
fontFamily: 'Inter, sans-serif',
components: {
Button: {
defaultProps: {
variant: 'light',
},
},
},
};
}
}
export default new MyExtension();How overrides combine
initializeMantineTheme() runs once at load, on every installed extension. The Panel deep-merges each returned override - in installation order - into one object, runs it through Mantine's createTheme, and hands it to the top-level MantineProvider. So:
- Your override is merged, not replacing. You only name what you want to change; everything else keeps its Panel default.
- Multiple extensions can contribute. Two extensions setting
primaryColoris last-writer-wins (later install order wins). Two extensions setting different keys both take effect. - It's a deep merge, so nested stuff like
components.Button.defaultPropscombines key-by-key instead of clobbering the wholecomponentsmap.
INFO
The Panel mounts MantineProvider with defaultColorScheme='dark'. Most of its own styling is tuned for dark first, with light mode as a supported alternate. If your override touches colors, check both schemes before you ship.
What goes here
Anything Mantine already models: colors (including a custom 10-shade palette), primaryColor, primaryShade, fontFamily / headings, defaultRadius, spacing, shadows, and per-component defaultProps / classNames / styles. The full surface is upstream - see Mantine's theme object and styles overview - and all of it is fair game inside the object you return.
TIP
Setting a component's defaultProps in the theme is usually a cleaner way to restyle a whole class of component than a props interceptor. If all you want is "every Button defaults to variant='light'", the theme is the simpler tool. Reach for an interceptor only when the change depends on the incoming props.
CSS Variables and Tailwind Tokens
Below the theme object sits a layer of raw CSS custom properties. The Panel resolves Mantine's theme into --mantine-color-* variables on :root, scoped by color scheme through the [data-mantine-color-scheme="dark"] / [data-mantine-color-scheme="light"] attributes. It also defines its own tokens - the font stack, server-status colors, chart colors - in its app.css.
Your extension can ship its own src/app.css, and the build picks it up automatically and folds it into the final stylesheet. That lets you override variables the theme object doesn't reach. The chart colors are the common case - they're plain CSS vars, not part of the Mantine theme, so this is the only way to retheme them:
/* my-extension/src/app.css */
:root[data-mantine-color-scheme="dark"] {
--chart-grid-color: #2a2a2a;
--chart-tick-color: #e5e7eb;
--chart-series-1-border: #c084fc;
--chart-series-1-fill: rgba(192, 132, 252, 0.18);
--chart-series-2-border: #f0abfc;
--chart-series-2-fill: rgba(240, 171, 252, 0.18);
}
:root[data-mantine-color-scheme="light"] {
--chart-grid-color: #e5e7eb;
--chart-tick-color: #374151;
--chart-series-1-border: #9333ea;
--chart-series-1-fill: rgba(147, 51, 234, 0.15);
}Same trick works for the app background (--mantine-color-body) or any other --mantine-color-* variable.
A couple of things to watch:
- Scheme scoping matters. These vars are defined separately under the dark and light selectors. Override them under the matching scheme selector (or both) - not bare
:root- or the more specific scheme rule wins and your value gets ignored. - Load order between extensions isn't guaranteed. If two extensions fight over the same variable, the winner comes down to bundling order, which you don't control. Keep overrides scoped to your own surfaces where you can, and treat global variable overrides as a deliberate "theme extension" thing.
- Prefer the theme when a value is theme-derived. Overriding
--mantine-color-grape-7in CSS works, but if Mantine would normally compute it from your palette, set it ininitializeMantineTheme()instead so both schemes and all the derived shades stay consistent.
Hookable Components
Every element in the Panel's component library - Button, Card, Modal, Spinner, the inputs, all of it - gets wrapped before it's exported. Instead of exporting the bare component, each module does:
export default makeComponentHookable(Button);makeComponentHookable returns a thin wrapper that's a process-wide singleton. Importing @/elements/Button.tsx from anywhere - core code or your extension - gives you the same wrapper instance. It exposes three methods, and because the instance is shared, registering a hook changes every render of that component across the whole Panel:
Button.addPropsInterceptor((props) => props); // transform incoming props
Button.addRenderInterceptor((element, props) => element); // wrap/replace the output
Button.replaceBaseComponent(MyButton); // swap the implementation entirelyWhere to register hooks
Register hooks in your extension's initialize(). It runs once, before React renders anything, so the interceptors are in place before the first paint and apply uniformly from the very first render:
import { Extension, ExtensionContext } from 'shared';
import Button from '@/elements/Button.tsx';
class MyExtension extends Extension {
public initialize(ctx: ExtensionContext): void {
Button.addPropsInterceptor((props) => ({
...props,
radius: 'xl',
}));
}
}
export default new MyExtension();WARNING
Don't register hooks from inside a React component - not in a render body, an effect, or an event handler. The interceptor arrays are append-only with no dedupe, so registering on every render or click stacks duplicate hooks forever and will eventually misbehave. initialize() is the one right place.
addPropsInterceptor - transform props before render
The interceptor gets the props the component was called with and returns the props it should actually render with. Use it to force, default, or rewrite props everywhere a component is used:
Button.addPropsInterceptor((props) => ({
...props,
// push every non-destructive button to our brand variant,
// but leave red (destructive) buttons alone
variant: props.color === 'red' ? props.variant : 'gradient',
}));Interceptors run in registration order, each getting the previous one's output, so multiple extensions (and multiple hooks from the same extension) compose. Always spread the incoming props and return a superset - returning a fresh object that drops fields breaks the component for every caller, core included.
addRenderInterceptor - wrap or replace the output
Where a props interceptor changes what the component renders with, a render interceptor changes what comes out. It gets the already-created React element plus the (post-props-interceptor) props, and returns the element to actually render. Good for wrapping the component in extra markup, or swapping it out conditionally:
import { cloneElement } from 'react';
Button.addRenderInterceptor((element, props) => (
<div className='relative inline-block'>
{element}
{props.loading && <span className='my-extension-sparkle' />}
</div>
));You can also cloneElement(element, ...) to tweak the element rather than wrap it, or return something else entirely. Like props interceptors, these stack in registration order.
replaceBaseComponent - swap the implementation
The blunt one. replaceBaseComponent swaps out the underlying component the wrapper renders, leaving the singleton wrapper (and any interceptors already registered on it) in place. Every import site now renders your version instead of the Panel's:
import Button, { type ButtonProps } from '@/elements/Button.tsx';
function MyButton(props: ButtonProps) {
// your own implementation - has to honor the same props contract
return <button className='my-button' onClick={props.onClick} {...} />;
}
// inside initialize():
Button.replaceBaseComponent(MyButton);WARNING
This is a sharp tool, same family as route interceptors (see Mounting UI → Interceptors). You're taking over a component that core pages depend on, so:
- Honor the exact props contract. Your replacement has to accept and correctly handle every prop the original did (
ButtonPropshere), or you break callers you've never seen. - You own it now. When the Panel evolves its
Button, your replacement won't follow along - that maintenance is yours. - Last replacement wins. If two extensions both
replaceBaseComponentthe same component, only the last one in install order survives. Nothing merges.
Replace a base component only when intercepting props or wrapping the render genuinely can't get you there. For most "make it look different" goals, the theme, a props interceptor, or a render interceptor is the safer bet.
Compound and sub-components
Some elements ship sub-components, and each is independently hookable. Spinner carries Spinner.Centered and Spinner.Suspense, for instance, and Modal is exported alongside ModalFooter. Each is its own hookable wrapper, so hook them separately:
import Spinner from '@/elements/Spinner.tsx';
Spinner.Centered.addPropsInterceptor((props) => ({ ...props, size: 48 }));Hooking the parent (Spinner) doesn't hook the children (Spinner.Centered), and vice versa - they're distinct singletons.
Caveats for all three
- Everything is global. There's no per-page or per-extension scoping. A hook on
Buttonchanges the Panel's own buttons too. That's the whole point of the system, but it means a careless interceptor has a big blast radius. - Order is install order, which you don't control. Don't write hooks that assume they run first or last.
- Keep interceptors pure and cheap. They run on every render of a heavily-used component. No expensive work, no side effects.
Choosing the Right Layer
| You want to... | Use |
|---|---|
| Recolor the Panel, change fonts, radii, spacing | initializeMantineTheme() |
| Set a default prop on a whole class of component | Mantine theme components.X.defaultProps |
| Retheme charts, or override a CSS variable | Your extension's src/app.css |
| Force or rewrite props on one component everywhere | addPropsInterceptor |
| Wrap or conditionally swap a component's output | addRenderInterceptor |
| Replace a component's implementation wholesale | replaceBaseComponent (last resort) |
When more than one layer would work, prefer the one higher up the list - it's less coupled to internals and less likely to surprise anyone.