Theming support

Overview

Limitless template provides a very flexible theming support through a set of SCSS files and variables. Current version is also fully based on CSS variables, meaning all template styles can now be customized at runtime and build process is optional and suitable for deep changes. It includes the default theme in 2 color versions: light and dark. The main difference between version 3 and 4 is that dark theme in version 3 requires switching of CSS files and a separate set of SCSS files with another set of SCSS variables, version 4 includes only 1 set of SCSS files and dark theme is integrated in there.

SCSS and CSS variables are working together, this method allows to use all power of SASS pre-processor that plain CSS doesn't support. We generate CSS variables on root and component levels from SCSS variables and then adjust variables for dark theme. This is what is looks like in source files:

										
											// SCSS variables for light and dark themes
											$body-bg:                   #f1f4f9;
											$body-color:                $gray-900;
											$body-darkmode-bg:          #202125;
											$body-darkmode-color:       $white;

											// CSS variables in light theme
											:root,
											[data-color-theme=light] {
												--body-bg: #f1f4f9;
												--body-color: #1F2937;
											}

											// CSS variables in dark theme
											[data-color-theme=dark],
											html[data-color-theme=dark] [data-color-theme] {
												--body-bg: #202125;
												--body-color: #fff;
											}

											// Use variables in containers
											body {
												background-color: var(--body-bg);
												color: var(--body-color);
											}
										
									

All Bootstrap and 3rd-party components re-using this logic as well. Repeated variables in external libraries on one hand slightly affect the amount of code, but on the other hand allows us to remove lots of CSS properties and use a combination of SCSS and CSS variables instead, this results in a very flexible format of CSS output.

At this point of time not all Bootstrap components support CSS variables and it doesn't support theme switching at all, so the whole logic and mechanism of detecting/setting a theme in Limitless is built from scratch, including missing Bootstrap components. This will improve over time, but no significant changes are expected.

How to use

Light and dark themes can be used on root level and component level. This means you can change color theme for the entire page or separately per component. The latter is very useful if certain component colors need to be inversed if used in another dark component, e.g. text input in dark sidebar. All you need is to add data-color-theme="dark" attribute to either html tag or component container. This is how it looks:

										
											// Change entire page to dark mode
											<html lang="en" dir="ltr" data-color-theme="dark">
												...
											</html>

											// Change component theme to dark
											<div class="navbar navbar-dark navbar-expand-lg">
												<input type="text" class="form-control" placeholder="Search" data-color-theme="dark">
												...
												<div class="dropdown">
													<button type="button" class="btn btn-primary">Button</button>
													<div class="dropdown-menu" data-color-theme="dark">
														...
													</div>
												</div> 
											</div>
										
									

This method works for buttons, form controls, text, links, backgrounds etc. Same logic also works for light theme, useful if you need to force certain components to be displayed in light theme, e.g. show light dropdown in dark container. For this, use data-color-theme="light" attribute.

Both data-color-theme attributes simply replace CSS variables on both root and component level. SASS mixin that is responsible for this switching applies a few selectors to each component that enable theme switching at runtime:

										
											@mixin color-scheme($name, $level: '') {
												@if $name == 'dark' {
													@if ($level != 'root') {
														&[data-color-theme=dark],
														[data-color-theme=dark] &:not([data-color-theme]),
														html[data-color-theme=dark] & {
															color-scheme: dark;
															@content;
														}
													}
													@else {
														[data-color-theme=dark],
														html[data-color-theme=dark] [data-color-theme] {
															@content;
														}
													}
												}

												@if $name == 'light' {
													@if ($level != 'root') {
														&[data-color-theme=light],
														[data-color-theme=light] &:not([data-color-theme]),
														html[data-color-theme=light] & {
															@content;
														}
													}
													@else {
														[data-color-theme=light],
														html[data-color-theme=light] [data-color-theme] {
															@content;
														}
													}
												}
											}
										
									

This code is located in scss/bootstrap_limitless/mixins/_color-scheme.scss. It's based on default Bootstrap mixin, but extended with custom logic. The mixin includes optional 'root' attribute, which adds a relation to the page level instead of component level. Read next sections for more information.

Root level theming

Limitless includes 2 levels of variables - root and component. Root level contains global variables that are re-used in other places, this list can be easily extended in _root.scss file. Component-specific variables are added to main component container selectors (e.g. .dropdown-menu).

Root level includes 90 CSS variables, vast majority is generated dynamically from SCSS color maps located in _variables-custom.scss. You can re-use these variables to style any 3rd-party component or library. For quick and simple customizations you can change values in these variables, for deep customization you would still need to edit SCSS files. Here is a full list of CSS variables for the default light theme:

										
											:root,
											[data-color-theme=light] {
											    --body-font-size-lg: 1rem;
											    --body-font-size-sm: 0.75rem;
											    --body-font-size-xs: 0.625rem;
											    --body-line-height-computed: calc(1375rem / 1000);
											    --body-line-height-lg: 1.375;
											    --body-line-height-sm: 1.8334;
											    --body-line-height-xs: 2.2;
											    --component-active-bg: #0c83ff;
											    --component-active-bg-rgb: 12,131,255;
											    --component-active-color: #fff;
											    --focus-ring-box-shadow: 0 0 0 0.125rem rgba(12, 131, 255, 0.25);
											    --spacer-1: 0.3125rem;
											    --spacer-2: 0.625rem;
											    --spacer: 1.25rem;
											    --spacer-4: 1.875rem;
											    --spacer-5: 3.75rem;
											    --icon-font-family: Phosphor;
											    --icon-font-size: 1.25rem;
											    --icon-font-size-lg: 1.5rem;
											    --icon-font-size-sm: 1rem;
											    --box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.125);
											    --box-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1);
											    --box-shadow-lg: 0 6px 12px rgba(0, 0, 0, 0.15);
											    --transition-base-timer: 0.15s;
											    --transition-collapse-timer: 0.3s;
											    --gray-100: #F9FAFB;
											    --gray-200: #F3F4F6;
											    --gray-300: #E5E7EB;
											    --gray-400: #D1D5DB;
											    --gray-500: #9CA3AF;
											    --gray-600: #6B7280;
											    --gray-700: #4B5563;
											    --gray-800: #374151;
											    --gray-900: #1F2937;
											    --indigo: #5C6BC0;
											    --purple: #8e70c1;
											    --pink: #f35c86;
											    --teal: #26A69A;
											    --yellow: #ffd648;
											    --primary: #0c83ff;
											    --secondary: #247297;
											    --success: #059669;
											    --info: #049aad;
											    --warning: #f58646;
											    --danger: #EF4444;
											    --light: #F3F4F6;
											    --dark: #252b36;
											    --black: #000;
											    --white: #fff;
											    --indigo-rgb: 92,107,192;
											    --purple-rgb: 142,112,193;
											    --pink-rgb: 243,92,134;
											    --teal-rgb: 38,166,154;
											    --yellow-rgb: 255,214,72;
											    --primary-rgb: 12,131,255;
											    --secondary-rgb: 36,114,151;
											    --success-rgb: 5,150,105;
											    --info-rgb: 4,154,173;
											    --warning-rgb: 245,134,70;
											    --danger-rgb: 239,68,68;
											    --light-rgb: 243,244,246;
											    --dark-rgb: 37,43,54;
											    --black-rgb: 0,0,0;
											    --white-rgb: 255,255,255;
											    --body-color-rgb: 31,41,55;
											    --body-bg-rgb: 241,244,249;
											    --font-sans-serif: "Inter",system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
											    --font-monospace: SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
											    --gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
											    --body-font-family: var(--font-sans-serif);
											    --body-font-size: 0.875rem;
											    --body-font-weight: 400;
											    --body-line-height: 1.5715;
											    --body-color: #1F2937;
											    --body-bg: #f1f4f9;
											    --border-width: 1px;
											    --border-style: solid;
											    --border-color: #D1D5DB;
											    --border-color-translucent: rgba(0, 0, 0, 0.125);
											    --border-radius: 0.375rem;
											    --border-radius-sm: 0.25rem;
											    --border-radius-lg: 0.5rem;
											    --border-radius-xl: 1rem;
											    --border-radius-2xl: 2rem;
											    --border-radius-pill: 50rem;
											    --link-color: #0c83ff;
											    --link-hover-color: #0962bf;
											    --code-color: #f35c86;
											    --highlight-bg: rgba(0, 0, 0, 0.15);
											}
										
									

The logic of dark theme is simple: when you add data-color-theme="dark" attribute, default CSS variables are getting overridden based on another selector. This is what is looks like:

										
											[data-color-theme=dark],
											html[data-color-theme=dark] [data-color-theme] {
												color-scheme: dark;
												--gray-100: #39393d;
												--gray-200: #3f4044;
												--gray-300: #46474a;
												--gray-400: #4d4d51;
												--gray-500: #6e6f71;
												--gray-600: #909092;
												--gray-700: #b1b1b3;
												--gray-800: #d2d3d3;
												--gray-900: #f4f4f4;
												--dark: #08090a;
												--dark-rgb: 8,9,10;
												--light: #383940;
												--light-rgb: 56,57,64;
												--body-bg: #202125;
												--body-color: #fff;
												--body-color-rgb: 255,255,255;
												--border-color: #4d4d51;
												--border-color-translucent: rgba(255, 255, 255, 0.125);
												--link-color: #6db5ff;
												--link-rgb-color: 109,181,255;
												--link-hover-color: #a0cfff;
												--highlight-bg: rgba(255, 255, 255, 0.3);
												--code-color: #f57d9e;
											}
										
									

As you can see, not all variables need new values, only the ones that need adjustments.

The color-scheme: dark; is a useful selector, when it's set to light or dark,the operating system makes adjustments to the user interface. This includes form controls, scrollbars, and the used values of CSS system colors. A quick example would be to display dark theme scrollbars in components even if a page is in light mode.

Component level theming

The second use case is component-based variables. This logic comes from Bootstrap framework and was significantly extended in Limitless. This method has upsides and downsides: upside is each component can be completely isolated from others, downside - each component requires its own set of variables. For example - if in SCSS we can simply re-apply same variable in all components, CSS variables need to be different, but they can still re-use SCSS variables. This is how it works:

										
											// Bootstrap modal variables
											.modal {
												--#{$prefix}modal-zindex: #{$zindex-modal};
												--#{$prefix}modal-width: #{$modal-md};
												--#{$prefix}modal-padding: #{$modal-inner-padding};
												--#{$prefix}modal-margin: #{$modal-dialog-margin};
												--#{$prefix}modal-color: #{$modal-content-color};
												--#{$prefix}modal-bg: #{$modal-content-bg};
												--#{$prefix}modal-border-color: #{$modal-content-border-color};
												--#{$prefix}modal-border-width: #{$modal-content-border-width};
												--#{$prefix}modal-border-radius: #{$modal-content-border-radius};
											}

											// Any other modal triggered by 3rd-party library
											.custom-modal {
												--#{$prefix}custom-modal-zindex: #{$zindex-modal};
												--#{$prefix}custom-modal-width: #{$modal-md};
												--#{$prefix}custom-modal-padding: #{$modal-inner-padding};
												--#{$prefix}custom-modal-margin: #{$modal-dialog-margin};
												--#{$prefix}custom-modal-color: #{$modal-content-color};
												--#{$prefix}custom-modal-bg: #{$modal-content-bg};
												--#{$prefix}custom-modal-border-color: #{$modal-content-border-color};
												--#{$prefix}custom-modal-border-width: #{$modal-content-border-width};
												--#{$prefix}custom-modal-border-radius: #{$modal-content-border-radius};
											}
										
									

As you can see, SCSS variables are the same in both components, but CSS variables have different names. Another advantage here is that if you work with SCSS version, you can edit all values of CSS variables in _variables-core.scss and _variables-custom.scss files.

Usage is pretty simple here, all you need is to add a list of variables to the main container selector and use those vars in CSS properties. To apply dark theme, use color-scheme() mixin in the same container. A quick example:

										
											// Variables from _variables-core.scss
											$dropdown-bg:                          var(--#{$prefix}white);
											$dropdown-darkmode-bg:                 lighten($body-darkmode-bg, 7.5%) !default;
											$dropdown-border-color:                var(--#{$prefix}border-color-translucent);
											$dropdown-darkmode-border-color:       rgba(var(--#{$prefix}black-rgb), .2) !default;

											// Dropdown menu theming
											.dropdown-menu {
											    --#{$prefix}dropdown-bg: #{$dropdown-bg};
											    --#{$prefix}dropdown-border-color: #{$dropdown-border-color};

											    // Dark theme
											    @include color-scheme(dark) {
											        --#{$prefix}dropdown-bg: #{$dropdown-darkmode-bg};
											        --#{$prefix}dropdown-border-color: #{$dropdown-darkmode-border-color};
											    }
											}
										
									

Each SCSS variable that needs adjustment in dark theme, has additional suffix - darkmode, all of them are stored in same files.

Switching between themes

Altough light and dark themes are all done in S/CSS, switching mechanism is purely based on JavaScript. Limitless includes theme switcher that includes 3 options: light, dark and system. Light and dark are manual switchers, system is some sort of automatic mode that sets a theme dynamically based on your operating system scheme. You can re-use the code or create your own, attach it to dropdown menu or buttons, form controls or selects, it's up to you. More options will be added in the next updates.

You can find JS code for theme switcher in template/assets/demo/demo_configurator.js file, here is it:

										
											// Check localStorage on page load and set mathing theme/direction
											(function () {
												((localStorage.getItem('theme') == 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) || localStorage.getItem('theme') == 'dark') && document.documentElement.setAttribute('data-color-theme', 'dark');
											})();

											// Theme configuration
											var layoutTheme = function() {
												var primaryTheme = 'light';
												var secondaryTheme = 'dark';
												var storageKey = 'theme';
												var colorscheme = document.getElementsByName('main-theme');
												var mql = window.matchMedia('(prefers-color-scheme: ' + primaryTheme + ')');

												// Changes the active radiobutton
												function indicateTheme(mode) {
													for(var i = colorscheme.length; i--; ) {
														if(colorscheme[i].value == mode) {
															colorscheme[i].checked = true;
															colorscheme[i].closest('.list-group-item').classList.add('bg-primary', 'bg-opacity-10', 'border-primary');
														}
														else {
															colorscheme[i].closest('.list-group-item').classList.remove('bg-primary', 'bg-opacity-10', 'border-primary');
														}
													}
												};

												// Turns alt stylesheet on/off
												function applyTheme(mode) {
													var st = document.documentElement;
													if (mode == primaryTheme) {
														st.removeAttribute('data-color-theme');
													}
													else if (mode == secondaryTheme) {
														st.setAttribute('data-color-theme', 'dark');
													}
													else {
														if (!mql.matches) {
															st.setAttribute('data-color-theme', 'dark');
														}
														else {
															st.removeAttribute('data-color-theme');
														}
													}
												};

												// Handles radiobutton clicks
												function setTheme(e) {
													var mode = e.target.value;
													document.documentElement.classList.add('no-transitions');
													if ((mode == primaryTheme)) {
														localStorage.removeItem(storageKey);
													}
													else {
														localStorage.setItem(storageKey, mode);
													}
													// When the auto button was clicked the auto-switcher needs to kick in
													autoTheme(mql);
												};

												// Handles the media query evaluation, so it expects a media query as parameter
												function autoTheme(e) {
													var current = localStorage.getItem(storageKey);
													var mode = primaryTheme;
													var indicate = primaryTheme;
													// User set preference has priority
													if ( current != null) {
														indicate = mode = current;
													}
													else if (e != null && e.matches) {
														mode = primaryTheme;
													}
													applyTheme(mode);
													indicateTheme(indicate);
													setTimeout(function() {
														document.documentElement.classList.remove('no-transitions');
													}, 100);
												};

												// Create an event listener for media query matches and run it immediately
												autoTheme(mql);
												mql.addListener(autoTheme);

												// Set up listeners for radio button clicks
												for(var i = colorscheme.length; i--; ) {
													colorscheme[i].onchange = setTheme;
												}
											};
										
									

By default, theme switcher is attached to 3 radio buttons and light theme is the default one. Selected theme is stored in localStorage and is being automatically loaded on page load.