Theme
Apply a bundled or custom theme to every user with interface.theme in librechat.yaml
Overview
interface.theme sets the deployment theme: the colors, shape, typography, shadows and motion that every user sees, in both light and dark mode. It takes either the name of a theme bundled with LibreChat or an inline theme definition.
Availability
interface.theme and the appearance scales described on this page are on LibreChat's canary
branch and are not part of a tagged release yet.
interface:
theme: clickhouseWhen interface.theme is unset, LibreChat behaves as before: users see the default LibreChat theme, or whatever build-time colors or stored theme apply to them.
This page covers what an operator sets in librechat.yaml. The theme engine itself, including the full token list and how each token maps to Tailwind utilities, is documented in the theme README in the LibreChat repository.
How the theme is chosen
The client picks one theme, highest priority first:
- High-contrast modes. A user who picks
high-contrast-lightorhigh-contrast-dark, or whose system setting resolves to high contrast, always gets the built-in accessible palette. interface.themefromlibrechat.yaml.REACT_APP_THEME_*build-time colors. See Theme Colors.- The user's stored theme in their browser.
The deployment theme sets colors, shape, fonts, shadows and motion, but not the mode: each user still chooses light, dark or system themselves.
The deployment theme is never written to the user's browser storage. If you remove interface.theme, users get their own stored theme back (or the REACT_APP_THEME_* colors, when the build sets them).
The theme is part of the pre-login configuration, so the login and registration pages already render with it.
Shared links
A shared link paints the theme of the tenant that owns the link, not the theme of the person viewing it. If the owning tenant sets no interface.theme, or its shared-link configuration fails to load, the shared page shows no deployment theme rather than falling back to the viewer's. Leaving the shared page restores the viewer's theme.
Bundled themes
| Name | Theme |
|---|---|
librechat | The default LibreChat palette and shape. Setting it explicitly also overrides REACT_APP_THEME_* colors and users' stored themes. |
clickhouse | A reference theme built from ClickHouse's Click UI design tokens. See ClickHouse theme. |
A name that is not one of these is ignored: the server logs it and drops interface.theme (see Validation), and the app falls back to the next source in the list above.
ClickHouse theme
interface.theme: clickhouse changes both color and shape:
- Palette. Every color token is defined in both modes from Click UI's light and dark tokens, so nothing falls back to the LibreChat palette. A few values are moved along their Click UI ramps where the verbatim value missed WCAG AA contrast.
- Accent. Near-black
#151515in light mode and the ClickHouse yellow#faff69in dark mode, used for the accent, submit button and focus ring. - Radii. A tighter scale taken from Click UI's
border.radii: controls andsmthroughlgat0.25rem, surfaces andxl/2xlat0.5rem, large surfaces and3xlat0.75rem. - Shadows. Click UI's single elevation shadow on every raised surface, at 0.15 opacity in light mode and 0.6 in dark, with a hairline shadow for the
xsandsmsteps. - Controls. Disabled controls use the
fillstyle with their own disabled colors, the switch is a compact 2rem by 1rem, and table rows get a 1px rule. - Scrims. Every dialog scrim is at 0.75 opacity.
- Fonts. The UI font stays Inter. Code uses Inconsolata, which LibreChat bundles, followed by the same system monospace fallbacks as the default theme. Headings and dialog titles ask for Basier Square first, which is not bundled, so they render in Inter unless the viewer has it installed.
Inline theme definition
Instead of a name, interface.theme can hold a theme definition. Anything you leave out falls back to LibreChat's defaults for that mode, so a definition only needs the values you want to change.
interface:
theme:
version: 1
name: acme
modes:
light:
colors:
rgb-accent-primary: '29 78 216'
rgb-accent-primary-hover: '30 64 175'
rgb-ring-primary: '29 78 216'
rgb-surface-submit: '29 78 216'
rgb-surface-submit-hover: '30 64 175'
rgb-link: '29 78 216'
appearance:
controlRadius: '0.375rem'
radiusLg: '0.375rem'
radiusXl: '0.5rem'
shadowLg: '0 8px 16px -4px rgb(0 0 0 / 0.2)'
dark:
colors:
rgb-accent-primary: '96 165 250'
rgb-accent-primary-hover: '147 197 253'
rgb-ring-primary: '96 165 250'
rgb-surface-submit: '37 99 235'
rgb-surface-submit-hover: '29 78 216'
rgb-link: '96 165 250'
appearance:
controlRadius: '0.375rem'
radiusLg: '0.375rem'
radiusXl: '0.5rem'
shadowLg: '0 8px 16px -4px rgb(0 0 0 / 0.5)'
brands:
provider-openai: '#10a37f'| Key | Type | Description |
|---|---|---|
version | Number | Required. Must be 1. |
name | String | Required. A non-empty name for the theme. |
modes | Object | Required. Holds light and/or dark. A mode you leave out uses LibreChat's defaults for that mode. |
modes.<mode>.colors | Object | Color tokens for that mode. Keys are rgb-* token names, values are R G B triplets. See Colors. |
modes.<mode>.appearance | Object | Shape, control, typography, scrim, shadow and motion values for that mode. See Appearance. |
modes.<mode>.brands | Object | Provider brand colors for that mode. Overrides the theme-wide brands. |
brands | Object | Provider brand colors for both modes. See Brands. |
Validation
The server checks interface.theme with the same rules the browser applies before painting it, when librechat.yaml is loaded and on every config reload.
An invalid theme is dropped as if interface.theme were unset. A wrong version, a missing name, a mode other than light or dark, a field the definition format does not have (at the top level, such as css, or inside a mode, anything other than colors, appearance and brands), an unknown brand token, or a value of the wrong kind for any token makes the theme unusable. LibreChat removes interface.theme, logs a warning with one line per problem, and loads the rest of the configuration as usual, so a theme mistake never stops the server. Users then get the next source in How the theme is chosen: the REACT_APP_THEME_* colors or their stored theme, or LibreChat's default theme when neither applies. The log calls this the default theme:
Ignoring interface.theme in /app/librechat.yaml; the default theme applies instead:
- interface.theme.modes.dark.colors.rgb-surface-primary: Invalid RGB value for rgb-surface-primary: 300 16 32A bundled theme name that does not exist is handled the same way (Unknown bundled theme "<name>", expected one of: librechat, clickhouse).
The colors and appearance maps work differently from the fields around them.
An unknown token is ignored, and the rest of the theme applies. A key inside colors or appearance that this version of LibreChat does not know, whether a typo or a token added in a newer version, costs only itself. The server logs it and leaves unknown colors out of the theme it sends to the browser:
interface.theme in /app/librechat.yaml names tokens this version ignores:
- interface.theme.modes.light.colors.rgb-surfce-secondary: Unknown light color token ignored: rgb-surfce-secondaryAn unknown token is still checked for shape: a color name must be a plain lowercase token (letters, digits and -) holding an R G B value, and an appearance name must be camelCase holding a plain value without ;, {, }, <, > or url(). Anything else is an error and the whole theme falls back. Check the server log after changing a theme, because a misspelled token is only reported there.
The browser applies the same rules to the theme it receives and logs [DeploymentTheme] Ignoring invalid interface.theme: ... if it still has to reject one, for example when an older cached client meets a newer server.
Colors
Colors use the same token names as the theme engine: rgb- followed by the token, such as rgb-surface-primary, rgb-text-primary, rgb-border-medium, rgb-accent-primary or rgb-status-error-subtle. The full list of 106 tokens is themeColorTokens in packages/data-provider/src/theme.ts, which the server and the browser both read, and each token is described in the IThemeRGB interface in packages/client/src/theme/types/index.ts.
Beyond the surface, text, border, status and syntax palettes, these roles let a theme restyle specific interaction states and components:
| Role | Tokens | Paints |
|---|---|---|
| Focus | rgb-focus-outline, rgb-focus-control | The keyboard focus outline across the app, and the focus ring of shared controls |
| Pressed | rgb-surface-pressed, rgb-surface-inverted-pressed | Neutral and inverted controls while pressed |
| Inverted and fixed | rgb-surface-inverted, rgb-surface-inverted-hover, rgb-text-inverted, rgb-surface-fixed, rgb-surface-fixed-hover, rgb-text-fixed | Controls drawn in the opposite mode's colors, and controls that keep one color in both modes |
| Disabled | rgb-surface-disabled, rgb-text-disabled, rgb-border-disabled | Disabled controls, when disabledStyle is fill (see Appearance) |
| Control border | rgb-border-control | The edge of inputs, select triggers and one-time code slots, kept separate from quiet separators because it needs 3:1 contrast |
| Scrim | rgb-surface-overlay | The color behind dialogs; its strength is set by the scrim opacities in Appearance |
| Switch | rgb-switch-unchecked, rgb-switch-thumb | The unchecked switch track, and the knob in both states |
| Table | rgb-table-header-text, rgb-table-header-fill | Column names, and the opaque fill of a sticky table header |
| Chart series | rgb-series-1 through rgb-series-8 | Categorical chart colors, in order |
Each value is a bare R G B triplet with every channel from 0 to 255, for example '255 255 255'. Hex values and rgb(...) are rejected. Quote the value so YAML reads it as a string.
A few tokens follow a related token you did set when you leave them out, so a partial palette stays coherent. For example, rgb-text-muted follows rgb-text-tertiary, rgb-surface-composer-hover and rgb-surface-pressed follow rgb-surface-hover, rgb-focus-outline follows rgb-ring-primary, rgb-focus-control follows rgb-text-primary, rgb-border-control follows rgb-border-light, rgb-switch-thumb follows rgb-surface-primary, rgb-table-header-text follows rgb-text-secondary, and rgb-table-header-fill follows rgb-surface-dialog.
Appearance
Appearance values are set per mode, and a mode without them uses the defaults below. To change shape in both modes, repeat the values under light and dark, as in the example above.
| Key | Controls | Default |
|---|---|---|
controlRadius | Corner radius of controls such as buttons and inputs | 0.75rem |
roundControlRadius | Radius of fully rounded controls | 9999px |
surfaceRadius | Radius of surfaces such as cards and menus | 1rem |
largeSurfaceRadius | Radius of large surfaces such as dialogs | 1.5rem |
radiusSm | The rounded-sm step used across the app | calc(0.5rem - 4px) |
radiusMd | The rounded-md step | calc(0.5rem - 2px) |
radiusLg | The rounded-lg step | 0.5rem |
radiusXl | The rounded-xl step | 0.75rem |
radius2xl | The rounded-2xl step | 1rem |
radius3xl | The rounded-3xl step | 1.5rem |
controlHeight | Height of standard controls | 2.25rem |
switchWidth | Width of the switch | 2.75rem |
switchHeight | Height of the switch; the knob is this minus the track's 4px border | 1.5rem |
tableCellSpaceY | Vertical padding of table cells | 1rem |
tableRowStroke | Thickness of the rule between table rows | 0px |
spaceCompact | Compact spacing step | 0.375rem |
spaceNormal | Normal spacing step | 0.75rem |
disabledStyle | How disabled controls look: dim fades them to 50% opacity, fill paints them with the disabled color roles | dim |
fontFamily | UI font family (font-sans) | Inter, sans-serif |
monoFontFamily | Code font family (font-mono) | 'Roboto Mono', ui-monospace, SFMono-Regular, Menlo, 'Cascadia Mono', 'Liberation Mono', Consolas, monospace |
displayFontFamily | Headings and dialog titles (font-display); follows fontFamily when unset | Inter, sans-serif |
textXs | The text-xs size | 0.75rem |
textSm | The text-sm size | 0.875rem |
textBase | The text-base size | 1rem |
textLg | The text-lg size | 1.125rem |
textXl | The text-xl size | 1.25rem |
text2xl | The text-2xl size | 1.5rem |
leadingXs | Line height paired with text-xs | calc(1 / 0.75) |
leadingSm | Line height paired with text-sm | calc(1.25 / 0.875) |
leadingBase | Line height paired with text-base | calc(1.5 / 1) |
leadingLg | Line height paired with text-lg | calc(1.75 / 1.125) |
leadingXl | Line height paired with text-xl | calc(1.75 / 1.25) |
leading2xl | Line height paired with text-2xl | calc(2 / 1.5) |
scrimOpacity | Strength of the scrim behind standard dialogs | 0.8 |
alertScrimOpacity | Strength of the scrim behind confirmation dialogs | 0.9 |
modalScrimOpacity | Strength of the scrim behind other modal dialogs | 0.65 |
elevationSurface | Shadow of raised theme surfaces | 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1) |
shadow2xs | The shadow-2xs step | 0 1px rgb(0 0 0 / 0.05) |
shadowXs | The shadow-xs step | 0 1px 2px 0 rgb(0 0 0 / 0.05) |
shadowSm | The shadow-sm step and bare shadow | 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1) |
shadowMd | The shadow-md step | 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) |
shadowLg | The shadow-lg step | 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1) |
shadowXl | The shadow-xl step | 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1) |
shadow2xl | The shadow-2xl step | 0 25px 50px -12px rgb(0 0 0 / 0.25) |
motionFast | Duration of fast transitions | 150ms |
motionNormal | Duration of normal transitions | 200ms |
The defaults reproduce LibreChat's look, so a theme that sets none of these keys changes no shape.
Accepted values:
- Radii,
controlHeight, spacing and text sizes:0, or a number inpx,remorem(such as0.25rem), or a singlecalc()of two such lengths (such ascalc(0.5rem - 2px)). switchWidthandswitchHeight: a positive length inpxorrem. Both must use the same unit (a side you leave out uses itsremdefault), the width must exceed the height so the knob can travel, and the height must clear the 4px track border (more than4px, or at least0.5rem).tableCellSpaceYandtableRowStroke:0, or a length inpxorrem.disabledStyle:dimorfill.- Line heights: a unitless number (such as
1.5), a singlecalc()dividing two numbers (such ascalc(1.25 / 0.875)), or a length. - Scrim opacities: a number from
0to1. - Font families: any non-empty
font-familylist without;,{or}. The font must be available to the browser: LibreChat bundles Inter, Roboto Mono and Inconsolata, so any other family has to be installed on the viewer's machine or served by your deployment, or the next family in the list is used. - Shadow steps (
shadow2xsthroughshadow2xl): a concretebox-shadowlist, ornone.var(),env(),attr()andurl()are rejected. elevationSurface: any non-emptybox-shadowvalue without;,{,}orurl().- Motion: a duration in
msors, such as120ms.
Brands
brands recolors the provider icons shown next to models. It can be set once at the top level for both modes, and overridden per mode under modes.<mode>.brands.
| Key | Default |
|---|---|
provider-openai | #19C37D |
provider-openai-gpt4 | #AB68FF |
provider-openai-reasoning | #000000 |
provider-anthropic | #d09a74 |
provider-azure | linear-gradient(0.375turn, #61bde2, #4389d0) |
provider-bedrock | #268672 |
provider-foreground | #ffffff |
Values are hex colors (#rgb, #rrggbb or #rrggbbaa). The fills may also be a linear-gradient(...); provider-foreground, the icon glyph color, must be a hex color.
Using the theme outside LibreChat
The @librechat/client package publishes the theme as a stylesheet, so another app built on its components can paint the same tokens. Import @librechat/client/theme.css in the stylesheet that imports Tailwind:
@import 'tailwindcss';
@import '@librechat/client/theme.css';It carries the semantic color tokens and their Tailwind mappings, the default value of every color and appearance token, and the @font-face rules for Inter, Roboto Mono and Inconsolata. The font files ship in the package as @librechat/client/fonts/*, so the app's bundler (Vite, webpack or esbuild) has to resolve and emit them; a setup that serves compiled CSS without a bundler has to serve those paths itself. A font is only downloaded when text renders in it.
How is this guide?