Project: Themeable UI Kit
Time to put the whole course together. In this project you will build a small UI kit, a set of reusable styles, for the landing page of "Ghar Ka Khana", a home-cooked tiffin service in Pune. The kit has design tokens, three colour themes (light, dark and a monsoon theme), helper functions and mixins, BEM components and a responsive layout. Sass generates everything from a few maps, and CSS custom properties let visitors switch theme live.
You will build it step by step in the editor, then see the compiled CSS running on a real landing page with a theme switcher, and finally split it into the folder structure you would use in a real project.
What you will build
- Design tokens stored in one nested map: background, surface, text, brand and on-brand colours for each theme.
- Themes generated by a loop as CSS custom properties, so switching theme needs no recompiling.
- Helpers:
rem(),space()andbp()functions;up(),focus-ringandtheme-vars()mixins. - Components: buttons with variants, cards, a header, a hero and a feature grid, all named with BEM.
- A safety check that warns at compile time if a theme puts white text on a light brand colour.
The editor compiles one file, so during the project everything lives in one file divided by comments. Step 6 shows how the same code is split into partials.
Step 1: tokens and themes
The heart of the kit is a map of themes. Each theme is itself a map of named colours. Colours are written in oklch so that lightness is easy to reason about. A mixin turns one theme map into custom properties, and a loop writes one block per theme. The default theme also gets :root, so the page works without any data-theme attribute:
@use "sass:color";
@use "sass:map";
$themes: (
light: (bg: oklch(98% 0.01 350), surface: white, text: oklch(25% 0.02 350), brand: oklch(55% 0.17 350), on-brand: white),
dark: (bg: oklch(20% 0.02 350), surface: oklch(27% 0.03 350), text: oklch(94% 0.01 350), brand: oklch(75% 0.13 350), on-brand: white),
);
$default-theme: light;
@mixin theme-vars($theme) {
@each $name, $value in $theme {
--#{$name}: #{$value};
}
--brand-soft: color-mix(in oklch, var(--brand) 15%, var(--surface));
}
@each $name, $theme in $themes {
$root: if(sass($name == $default-theme): ":root, "; else: "");
$l: color.channel(map.get($theme, brand), "lightness", $space: oklch);
@if $l > 70% and map.get($theme, on-brand) == white {
@warn "Theme `#{$name}`: white text on a light brand colour (#{$l}) will be hard to read.";
}
#{$root}[data-theme="#{$name}"] {
@include theme-vars($theme);
}
}WARNING: Theme `dark`: white text on a light brand colour (75%) will be hard to read.
:root, [data-theme=light] {
--bg: oklch(98% 0.01 350deg);
--surface: white;
--text: oklch(25% 0.02 350deg);
--brand: oklch(55% 0.17 350deg);
--on-brand: white;
--brand-soft: color-mix(in oklch, var(--brand) 15%, var(--surface));
}
[data-theme=dark] {
--bg: oklch(20% 0.02 350deg);
--surface: oklch(27% 0.03 350deg);
--text: oklch(94% 0.01 350deg);
--brand: oklch(75% 0.13 350deg);
--on-brand: white;
--brand-soft: color-mix(in oklch, var(--brand) 15%, var(--surface));
}Read the output carefully:
- Interpolation built the property names (
--bg,--brand) from the map keys, and#{$value}inserted the real colours into the custom properties. if(sass(...))added:root,only for the default theme.- The dark theme triggered the warning: its brand colour has 75% lightness, and white text on it would be hard to read. This is Sass doing what CSS cannot: checking your design tokens before anything reaches the browser.
--brand-softuses nativecolor-mix(). It stays as written, so the browser recalculates it whenever--brandchanges. Sass decides the themes; the browser mixes the colours live.
Fix the warning by changing the dark theme's on-brand to oklch(20% 0.02 350), a dark text colour, and run again: the warning disappears.
Step 2: functions and mixins
Next come the helpers every component will use. space() keeps all spacing on an 8px grid but outputs rem, bp() refuses unknown breakpoint names, and focus-ring gives every interactive component the same visible keyboard focus style:
@use "sass:map";
@use "sass:math";
$breakpoints: (md: 48rem, lg: 64rem);
$space-unit: 8px;
@function rem($px) {
@return math.div($px, 16px) * 1rem;
}
@function space($steps) {
@return rem($steps * $space-unit);
}
@function bp($name) {
@if not map.has-key($breakpoints, $name) {
@error "Unknown breakpoint `#{$name}`.";
}
@return map.get($breakpoints, $name);
}
@mixin up($name) {
@media (width >= bp($name)) { @content; }
}
@mixin focus-ring {
&:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
}
// a quick test of the helpers
.test {
padding: space(1) space(2.5);
@include focus-ring;
@include up(md) {
padding: space(3);
}
}.test {
padding: 0.5rem 1.25rem;
}
.test:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
@media (width >= 48rem) {
.test {
padding: 1.5rem;
}
}space(2.5) gave 1.25rem (20px). The focus ring uses var(--brand), so it automatically matches whichever theme is active. Try up(xl) to see the error.
Step 3: BEM components from maps
Buttons come in variants. Instead of writing each one by hand, a map lists the variants and a loop inside the block builds BEM modifier classes with &--. Cards use BEM elements with &__:
@use "sass:map";
$radius: 12px;
$button-variants: (
primary: (bg: var(--brand), text: var(--on-brand)),
soft: (bg: var(--brand-soft), text: var(--text)),
);
.btn {
display: inline-block;
padding: 0.5rem 1.25rem;
border: 0;
border-radius: $radius;
font: inherit;
font-weight: 600;
@each $name, $v in $button-variants {
&--#{$name} {
background: map.get($v, bg);
color: map.get($v, text);
}
}
}
.card {
background: var(--surface);
border-radius: $radius;
padding: 1.5rem;
&__title {
margin: 0 0 0.5rem;
color: var(--brand);
}
}.btn {
display: inline-block;
padding: 0.5rem 1.25rem;
border: 0;
border-radius: 12px;
font: inherit;
font-weight: 600;
}
.btn--primary {
background: var(--brand);
color: var(--on-brand);
}
.btn--soft {
background: var(--brand-soft);
color: var(--text);
}
.card {
background: var(--surface);
border-radius: 12px;
padding: 1.5rem;
}
.card__title {
margin: 0 0 0.5rem;
color: var(--brand);
}Notice that the components never mention a real colour. They only use custom properties such as var(--brand) and var(--surface). That is what makes the kit themeable: the theme blocks from Step 1 set the values, and every component follows. Adding a third button variant is one new line in the map.
Step 4: the complete kit
Here is the whole kit in one file: tokens with a third "monsoon" theme, the helpers, base styles, the components and the layout (header, hero and a feature grid that becomes three columns from the md breakpoint). The dark theme now uses dark text on its light brand colour, so it compiles without warnings:
@use "sass:color";
@use "sass:map";
@use "sass:math";
// ---------- abstracts: tokens ----------
$themes: (
light: (bg: oklch(98% 0.01 350), surface: white, text: oklch(25% 0.02 350), brand: oklch(55% 0.17 350), on-brand: white),
dark: (bg: oklch(20% 0.02 350), surface: oklch(27% 0.03 350), text: oklch(94% 0.01 350), brand: oklch(75% 0.13 350), on-brand: oklch(20% 0.02 350)),
monsoon: (bg: oklch(97% 0.02 220), surface: white, text: oklch(25% 0.03 230), brand: oklch(50% 0.11 220), on-brand: white),
);
$breakpoints: (md: 48rem, lg: 64rem);
$default-theme: light;
$radius: 12px;
$space-unit: 8px;
// ---------- abstracts: functions ----------
@function rem($px) {
@return math.div($px, 16px) * 1rem;
}
@function space($steps) {
@return rem($steps * $space-unit);
}
@function bp($name) {
@if not map.has-key($breakpoints, $name) {
@error "Unknown breakpoint `#{$name}`.";
}
@return map.get($breakpoints, $name);
}
// ---------- abstracts: mixins ----------
@mixin up($name) {
@media (width >= bp($name)) { @content; }
}
@mixin focus-ring {
&:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
}
@mixin theme-vars($theme) {
@each $name, $value in $theme {
--#{$name}: #{$value};
}
--brand-soft: color-mix(in oklch, var(--brand) 15%, var(--surface));
}
// ---------- themes ----------
@each $name, $theme in $themes {
$root: if(sass($name == $default-theme): ":root, "; else: "");
$l: color.channel(map.get($theme, brand), "lightness", $space: oklch);
@if $l > 70% and map.get($theme, on-brand) == white {
@warn "Theme `#{$name}`: white text on a light brand colour (#{$l}) will be hard to read.";
}
#{$root}[data-theme="#{$name}"] {
@include theme-vars($theme);
}
}
// ---------- base ----------
body {
margin: 0;
font-family: system-ui, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
// ---------- components: button ----------
$button-variants: (
primary: (bg: var(--brand), text: var(--on-brand)),
soft: (bg: var(--brand-soft), text: var(--text)),
);
.btn {
display: inline-block;
padding: space(1) space(2.5);
border: 0;
border-radius: $radius;
font: inherit;
font-weight: 600;
text-decoration: none;
cursor: pointer;
@include focus-ring;
@each $name, $v in $button-variants {
&--#{$name} {
background: map.get($v, bg);
color: map.get($v, text);
}
}
}
// ---------- components: card ----------
.card {
background: var(--surface);
border-radius: $radius;
padding: space(3);
box-shadow: 0 rem(2px) rem(12px) rgb(0 0 0 / 0.08);
&__title {
margin: 0 0 space(1);
color: var(--brand);
}
}
// ---------- layout ----------
.site-header {
display: flex;
flex-wrap: wrap;
gap: space(1);
align-items: center;
justify-content: space-between;
padding: space(2);
&__logo {
font-weight: 800;
color: var(--brand);
}
}
.hero {
padding: space(6) space(2);
text-align: center;
&__title {
font-size: clamp(#{rem(28px)}, 5vw, #{rem(48px)});
margin: 0 0 space(2);
}
@include up(md) {
padding: space(10) space(4);
}
}
.features {
display: grid;
gap: space(2);
padding: 0 space(2) space(6);
max-width: rem(1080px);
margin: 0 auto;
@include up(md) {
grid-template-columns: repeat(3, 1fr);
}
}:root, [data-theme=light] {
--bg: oklch(98% 0.01 350deg);
--surface: white;
--text: oklch(25% 0.02 350deg);
--brand: oklch(55% 0.17 350deg);
--on-brand: white;
--brand-soft: color-mix(in oklch, var(--brand) 15%, var(--surface));
}
[data-theme=dark] {
--bg: oklch(20% 0.02 350deg);
--surface: oklch(27% 0.03 350deg);
--text: oklch(94% 0.01 350deg);
--brand: oklch(75% 0.13 350deg);
--on-brand: oklch(20% 0.02 350deg);
--brand-soft: color-mix(in oklch, var(--brand) 15%, var(--surface));
}
[data-theme=monsoon] {
--bg: oklch(97% 0.02 220deg);
--surface: white;
--text: oklch(25% 0.03 230deg);
--brand: oklch(50% 0.11 220deg);
--on-brand: white;
--brand-soft: color-mix(in oklch, var(--brand) 15%, var(--surface));
}
body {
margin: 0;
font-family: system-ui, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
.btn {
display: inline-block;
padding: 0.5rem 1.25rem;
border: 0;
border-radius: 12px;
font: inherit;
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.btn:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
.btn--primary {
background: var(--brand);
color: var(--on-brand);
}
.btn--soft {
background: var(--brand-soft);
color: var(--text);
}
.card {
background: var(--surface);
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 0.125rem 0.75rem rgba(0, 0, 0, 0.08);
}
.card__title {
margin: 0 0 0.5rem;
color: var(--brand);
}
.site-header {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
padding: 1rem;
}
.site-header__logo {
font-weight: 800;
color: var(--brand);
}
.hero {
padding: 3rem 1rem;
text-align: center;
}
.hero__title {
font-size: clamp(1.75rem, 5vw, 3rem);
margin: 0 0 1rem;
}
@media (width >= 48rem) {
.hero {
padding: 5rem 2rem;
}
}
.features {
display: grid;
gap: 1rem;
padding: 0 1rem 3rem;
max-width: 67.5rem;
margin: 0 auto;
}
@media (width >= 48rem) {
.features {
grid-template-columns: repeat(3, 1fr);
}
}About 100 lines of CSS from a file that is mostly maps, loops and short rules. To add a theme, add one entry to $themes; to add a button style, add one entry to $button-variants.
Step 5: the landing page
Here is the compiled CSS from Step 4 pasted into a page. Three buttons in the header change the data-theme attribute on the <html> element with a few lines of JavaScript. The CSS does not change at all: the browser simply picks up different custom property values.
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ghar Ka Khana, Pune</title>
<style>
:root, [data-theme=light] {
--bg: oklch(98% 0.01 350deg);
--surface: white;
--text: oklch(25% 0.02 350deg);
--brand: oklch(55% 0.17 350deg);
--on-brand: white;
--brand-soft: color-mix(in oklch, var(--brand) 15%, var(--surface));
}
[data-theme=dark] {
--bg: oklch(20% 0.02 350deg);
--surface: oklch(27% 0.03 350deg);
--text: oklch(94% 0.01 350deg);
--brand: oklch(75% 0.13 350deg);
--on-brand: oklch(20% 0.02 350deg);
--brand-soft: color-mix(in oklch, var(--brand) 15%, var(--surface));
}
[data-theme=monsoon] {
--bg: oklch(97% 0.02 220deg);
--surface: white;
--text: oklch(25% 0.03 230deg);
--brand: oklch(50% 0.11 220deg);
--on-brand: white;
--brand-soft: color-mix(in oklch, var(--brand) 15%, var(--surface));
}
body {
margin: 0;
font-family: system-ui, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
.btn {
display: inline-block;
padding: 0.5rem 1.25rem;
border: 0;
border-radius: 12px;
font: inherit;
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.btn:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
.btn--primary {
background: var(--brand);
color: var(--on-brand);
}
.btn--soft {
background: var(--brand-soft);
color: var(--text);
}
.card {
background: var(--surface);
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 0.125rem 0.75rem rgba(0, 0, 0, 0.08);
}
.card__title {
margin: 0 0 0.5rem;
color: var(--brand);
}
.site-header {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
padding: 1rem;
}
.site-header__logo {
font-weight: 800;
color: var(--brand);
}
.hero {
padding: 3rem 1rem;
text-align: center;
}
.hero__title {
font-size: clamp(1.75rem, 5vw, 3rem);
margin: 0 0 1rem;
}
@media (width >= 48rem) {
.hero {
padding: 5rem 2rem;
}
}
.features {
display: grid;
gap: 1rem;
padding: 0 1rem 3rem;
max-width: 67.5rem;
margin: 0 auto;
}
@media (width >= 48rem) {
.features {
grid-template-columns: repeat(3, 1fr);
}
}
</style>
</head>
<body>
<header class="site-header">
<span class="site-header__logo">Ghar Ka Khana</span>
<nav aria-label="Theme">
<button class="btn btn--soft" type="button" data-set-theme="light">Light</button>
<button class="btn btn--soft" type="button" data-set-theme="dark">Dark</button>
<button class="btn btn--soft" type="button" data-set-theme="monsoon">Monsoon</button>
</nav>
</header>
<main>
<section class="hero">
<h1 class="hero__title">Home-cooked tiffin, delivered in Pune</h1>
<p>Fresh dal, sabzi, roti and rice from home kitchens in Kothrud and Baner.</p>
<a class="btn btn--primary" href="#plans">See plans from ₹2,999 a month</a>
</section>
<section class="features" id="plans">
<article class="card">
<h2 class="card__title">Daily menu</h2>
<p>A new menu every day, planned by Meera and her team of home cooks.</p>
</article>
<article class="card">
<h2 class="card__title">Lunch by 1 pm</h2>
<p>Steel tiffins delivered to offices and hostels across the city.</p>
</article>
<article class="card">
<h2 class="card__title">Pause any day</h2>
<p>Travelling? Skip a day from the app and it is added to your plan.</p>
</article>
</section>
</main>
<script>
for (const button of document.querySelectorAll('[data-set-theme]')) {
button.addEventListener('click', () => {
document.documentElement.dataset.theme = button.dataset.setTheme;
});
}
</script>
</body>
</html>Click Dark and Monsoon. The background, cards, headings, buttons, the soft button tint and the focus ring all change together, and the dark theme's main button has dark text, exactly as the compile-time check required. Press Tab to see the focus ring from the focus-ring mixin.
Step 6: splitting it into files
In a real project, the single file becomes the folder structure from Folder Architecture. The abstracts produce no CSS and are forwarded through an index file; everything else loads them with @use:
scss/
abstracts/
_tokens.scss $themes, $breakpoints, $radius, $space-unit (all !default)
_functions.scss rem(), space(), bp()
_mixins.scss up(), focus-ring, theme-vars()
_index.scss @forward "tokens"; @forward "functions"; @forward "mixins";
themes/
_index.scss the @each loop that writes the theme blocks
base/
_index.scss body styles
components/
_button.scss
_card.scss
_index.scss
layout/
_header.scss
_hero.scss
_features.scss
_index.scss
main.scssA component partial loads the abstracts itself:
// components/_button.scss
@use "sass:map";
@use "../abstracts" as *;
.btn {
padding: space(1) space(2.5);
border-radius: $radius;
@include focus-ring;
// ... variants loop as in Step 3
}And the entry file configures the kit and loads each folder in order, from general to specific:
// main.scss
@use "abstracts" with ($radius: 8px);
@use "themes";
@use "base";
@use "components";
@use "layout";Because $radius is declared with !default in _tokens.scss, another site can reuse the whole kit with its own corner radius, or even its own $themes map, without editing a single partial. Compile it for production with npx sass --style=compressed --no-source-map scss/main.scss css/main.css (see Source Maps & Production Builds).
Ideas to extend the kit
- Add a
festivaltheme for Diwali offers, and check that the contrast warning catches a bad colour choice. - Generate a
.badgecomponent with modifiers from a map, like the buttons. - Add a
prefers-color-scheme: darkmedia query that applies the dark theme's variables when no theme has been chosen. - Store the chosen theme in
localStorageso it survives a page reload.
Key points
- Keep design decisions in maps: themes, breakpoints, button variants. Loops turn them into CSS.
- Generate CSS custom properties from Sass maps so themes can change live without recompiling.
- Let components use only custom properties and helper functions, never raw colours, so every theme applies everywhere.
- Use
@warnand@errorto check tokens and arguments at compile time. - Split the kit into abstracts, themes, base, components and layout, and configure it with
@use ... with.
Exercise
Extend the kit with a fourth theme called diwali with background oklch(97% 0.03 80), surface white, text oklch(25% 0.04 40), brand oklch(72% 0.17 60) and on-brand white. Compile it and read the warning. Then fix the theme so it compiles cleanly, add a fourth theme button to the page, and add a .btn--outline variant (transparent background, brand-coloured text) to $button-variants.
Show solution
$themes: (
// ... light, dark and monsoon as before
diwali: (bg: oklch(97% 0.03 80), surface: white, text: oklch(25% 0.04 40), brand: oklch(72% 0.17 60), on-brand: oklch(22% 0.04 40)),
);
$button-variants: (
primary: (bg: var(--brand), text: var(--on-brand)),
soft: (bg: var(--brand-soft), text: var(--text)),
outline: (bg: transparent, text: var(--brand)),
);<button class="btn btn--soft" type="button" data-set-theme="diwali">Diwali</button>With white on-brand, the compiler warns that the 72% lightness brand colour is too light for white text. A dark on-brand colour fixes it. The new map entries generate [data-theme=diwali] and .btn--outline without touching any other rule.
Quick Quiz
1. Why does the kit generate CSS custom properties instead of using Sass variables directly in components?
Sass values are fixed at build time; custom properties can switch live when data-theme changes.
2. What does if(sass($name == $default-theme): ":root, "; else: "") do in the theme loop?
The interpolated string becomes part of the selector, so the default theme also applies without a data-theme attribute.
3. Why is --brand-soft written with native color-mix() instead of color.mix()?
A Sass function would be calculated once; native color-mix() follows the live custom property.
4. How do you add a new button style to the kit?
The loop inside .btn generates a modifier class for every map entry.
Learn Sass with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
