How we upgraded docs customization

How we upgraded docs customization

Product updates

Product updates

Product updates

16 Apr

Author

Author

Author

Last week we covered the new customization features we launched for docs sites over the past few months. Since docs are a fundamental part of your product, it's important for the reading experience to be amazing, accessible, and aligned with your product. Customization is a key part of this. It's been a big focus for everyone at the company, and for me in particular.

👋 I'm Zeno, Design Engineer at GitBook. I've spearheaded the recent overhaul of our docs customization options, from its initial design to execution. The goal has been to turn a number of disparate features into a cohesive system that allows more room for expression. In this article I'll detail some of the thinking and building that went into this project, from high-level vision to technical rabbit holes I went down.

What's new?

As a quick recap, here are some of the features we've recently rolled out:

  • Evolving our existing header presets into four new site themes

  • A complete revamp of our color system, the introduction of tint color as a complement to the primary color, and recently semantic colors to fit your content to your brand's palette

  • Alternate component styles like sidebar styles, link styles, and hint blocks

  • Support for custom fonts

For more details about these features, you can read last week's post or check out the docs.

Themes and styles: one language for thousands of voices

GitBook's aim is to make it completely effortless to publish great-looking docs, so you can focus on writing great content instead. That means published sites need to look great out of the box. But your docs also need to speak with your product's voice, not GitBook's. Customization should thus be extensive (so you can truly make it yours) and super easy (without much investment from you). And this whole system needs to be cohesive: your docs should shine no matter what options you choose, which means every option needs to work well together with every other option.

So what options should we build, and how do they all relate to each other? How do we condense thousands of styles into a handful of choices? How do we build a language for thousands of voices to speak with?

To figure that out I benchmarked different docs sites, identified the common components, and grouped the different styles for each component into style "buckets". Based on this benchmark I made a baseline docs site layout and prototyped wildly different site designs on top of it that catered to the different vibes – from minimal to frivolous. I then started mixing and matching these designs into four distinct themes that set the creative direction and component styles that act as modifiers on top.

Systematic styling

Whereas the old header presets only influenced the header, themes work across the whole site: components can change their look depending on the chosen theme to fit in.

Four new themes

Styles act on top of themes, which means they need to work well on every single theme. To accomplish this, styles are chosen carefully to represent where to go, not how to get there.

An example. We introduced two sidebar styles to pick from: default has no background and filled adds a background. On the Clean theme, the filled background is darker than the site. But on the Muted theme, which itself adds a darker color to the site, the sidebar inverts its background to a lighter color for a stronger contrast.

Two different looks for the sidebar, on Clean and Muted

If we would have added explicit bg-dark and bg-light styles, the sidebar would look bad in some of these combinations. By clearly defining the intent of a style rather than its look, and then tweaking the style's implementation for every theme, we can make sure that every combination looks great.

Some styles make sense as a global option in the customization screen, while others work better on a per-component choice. They can be manual – like setting the card size on a Cards component – or automatically inferred – like how adding a heading to hint blocks makes its styling more prominent. The aim is to increase the total number of options without overwhelming choice.

By adding a heading block inside hints, they automatically get a new alternative style.

Since launch we've already added a bunch of new styles, most of which have come from customers asking for something that our styles simply didn't cover yet. For example, the new link styles and alternative hint block styling were directly inspired by a customer request. If you have a suggestion for a style we don't cover, please reach out and let us know!

Link styles

🐰🛠️ Technical rabbit hole: Tailwind custom variants

Before this project, we passed a customization object through our React tree to check for values and apply colors, but this would be unfeasible if we wanted to influence components throughout the whole site. So instead, to make these permutations easy to work with, I heavily utilised custom variants in Tailwind. We now dynamically generate them in our Tailwind config:

const config: Config = {
    plugins: [
        plugin(({ addVariant }) => {
            const customisationVariants = {
                sidebar: [
                    'sidebar-default',
                    'sidebar-filled',
                ], // Sidebar styles
                list: [
                    'sidebar-list-default',
                    'sidebar-list-pill',
                    'sidebar-list-line',
                ], // List styles
                tint: [
                    'tint',
                    'no-tint',
                ], // Tint colours
                theme: [
                    'theme-clean',
                    'theme-muted',
                    'theme-bold',
                    'theme-gradient',
                ], // Themes
                corner: [
                    'straight-corners',
                ], // Corner styles
                links: [
                    'links-default',
                    'links-accent',
                ], // Link styles
            };

            for (const [category, variants] of Object.entries(
                customisationVariants,
            )) {
                for (const variant of variants) {
                    addVariant(variant, `html.${variant} &`);
                }
            }
        }),
    ],
};

Upon launch, the site's theme and any global styles are appended as classes to the <html> tag. Through custom variants, we can write conditional CSS like theme-clean:bg-transparent theme-muted:bg-muted anywhere in our codebase, and it will conditionally apply these styles to the right themes.

One caveat of variants is that nesting them is not always straightforward or possible. Checking for the aforementioned classes on the <html> results in Tailwind statements like:

addVariant('theme-clean', 'html.theme-clean &');
addVariant('tint', 'html.tint &');

But let's say you want to conditionally style something for only the tinted version of the Clean theme using a statement like theme-clean:tint:bg-white. That statement would get interpreted as the following CSS:

html.theme-clean html.tint & {
    background: #fff /* Won't work */
}

This CSS selector would never select anything, because the html tag isn't nested. Instead we need a CSS selector that targets a double class, like:

html.theme-clean.tint & { /* Check for both classes to be present */
    background: #fff
}

But you can't express this with Tailwind's addVariant() function (or at least I haven't found a way). So instead, we generate "combi-variants" for commonly used combinations with some extra code:

for (const [category, variants] of Object.entries(
    customisationVariants,
)) {
    for (const variant of variants) {
        addVariant(
            variant,
            `html.${variant} &`,
        );
        // Same as before, generates e.g.
        // theme-clean, theme-muted, ...
        if (category === 'tint') {
            for (const themeVariant of
                customisationVariants.theme
            ) {
                addVariant(
                    `${themeVariant}-${variant}`,
                    // e.g. theme-clean-tint, ...
                    `html.${variant}.${themeVariant} &`,
                    // e.g. html.theme-clean.tint, ...
                );
            }
        }
    }
}

This results in three nice variants to work with in our codebase.

  • theme-clean: targets Clean no matter the tint color (html.theme-clean &)

  • theme-clean-tint: targets Clean with tint color set (html.theme-clean.tint &)

  • theme-clean-no-tint: targets Clean with no tint color set (html.theme-clean.no-tint &)

You can see the final Tailwind config and custom variants yourself on GitHub.

Colors and fonts: making it work for everyone

So with careful calibration we can make every theme & style combination look great, but there are two big modifiers left: colors and fonts. These are perhaps the most important things to make your docs site feel in line with your product, and they're also the most tricky since there are infinite possibilities for both. There's a tricky balance to be struck between form and function, as well as setting good defaults.

Thoughtful typography

We curate a short list of popular fonts to use on your site, with the typographic workhorse Inter as the default. The benefit of a curated list is that we can make stylistic overrides where needed, for example to set ligatures on fonts like Lato to the most appropriate versions.

Custom fonts place the responsibility for readable type mostly on the site publisher, but we can still guide them to make the right choices. For instance, we require at least two weights of a custom font because the front-end uses different font weights to distinguish important information from asides.

The font upload dialog helps set the required styles to make your custom font look good

​​As part of building custom fonts we also reevaluated the typographic lockups on published content, and spent a lot of time on fetching and rendering the font as early as possible to prevent flashes of unstyled text. One great example of custom fonts in action is on the newly-launched NVIDIA Run:ai docs.

Building color palettes

People care a lot about color. Functionally color is one of the most important factors for text readability, together with text size. Emotionally colors are one of your brand's most important and recognisable elements. For docs sites this means we need to represent any user-supplied color faithfully, while keeping the end result beautiful and readable for a large, diverse audience.

Before this project, site authors could supply one primary color that determined the look of many elements on the site, from links to backgrounds. Apart from that color, the system used a collection of different grays and default Tailwind values to style published content. It was a bit of a hodgepodge of colors and greys, and none of it was applied consistently across components.

The new system consists of two main colors: the primary color and the tint color, together with a few auxiliary colors for specific scenarios.

  • The primary color is supposed to be the main brand color. It colors primary navigational and interactive elements like buttons, links, and active tabs.

  • The tint color acts as a global modifier for nearly every part of the site, like the site background and components. In the Bold theme it colors the header, in Gradient it colors the main gradient. Nearly every color on the site uses a tint-* class. By default, the tint color is a neutral gray with a tiny bit of the primary color mixed in, resulting in a neutral-looking site. By picking a custom tint color, site authors can dramatically re-color their whole site while keeping their primary color for links.

  • Semantic colors are used for status messages across the site, like the different types of hint blocks, or the required text in API blocks. There are four semantic colors: info, success, warning, and danger. These colors can be overridden by site authors. Semantic colors were directly inspired by customer feedback about the arbitrary blue and green we used for our hint blocks before.

  • The neutral scale is a simple set of neutral grays that can be used for any neutral elements that should never be colored, like overlays or close buttons.

For each of these colors we dynamically generate a color palette consisting of 12 colors from very dark to very light. Each step of the scale is used for a specific purpose, like backgrounds, borders, or text. By picking an appropriate brightness (or perceptual lightness to be more precise, detailed below) for each step, we get a scale that has strong contrast with other steps, no matter which color it is based on.

Example of a tint color scale and where we use the steps of the scale

Form follows function

The generated docs sites are designed to be as accessible and readable as possible (surpassing WCAG 2.1 AA or AAA contrast levels) by default, while staying true to the site author's chosen brand colors where it matters. For example, links and buttons use the exact primary color the site author has specified, even if it doesn't meet the contrast guidelines.

To keep content accessible, we build in accessibility checks and alternate styles. When a visitor's browser requests high-contrast styles (set at the OS level and detected using the prefer-contrast CSS media feature), we always render a high-contrast color or add a differentation that doesn't rely on color. This results in a pretty different docs experience for those who need it.

Example of high-contrast styles: thicker lines and contrasting text colors

🐰🛠️ Technical rabbit hole: dynamic color palettes with perceptual lightness

The color scales used in our system are strongly inspired by the Radix color scale and use the same semantic steps, but the palette generation is completely custom. Radix consists of a number of handcrafted and manually tweaked color scales. Although you can generate a custom scale, it essentially returns the closest estimated pre-made scale, with some steps changed to the input color. Our system generates a completely dynamic palette based on an input color.

An example of a complete generated site palette. Step 9 is the originally supplied color for each scale.

Each color scale consists of 12 steps. For convenience it is split up into categories that are used in selectively generating Tailwind utility classes (more about that later). Each category has named colors within it, and each color references a step in the scale from 1 to 12. Some steps can become multiple named colors, e.g. accents.solid and text.subtle both reference step 9 of the scale.

export const scale: Record<ColorCategory, ColorSubScale> = {
    [ColorCategory.backgrounds]: {
        base: 1, // Base background
        subtle: 2, // Accent background
    },
    [ColorCategory.components]: {
        DEFAULT: 3, // Component background
        hover: 4, // Component hover background
        active: 5, // Component active background
    },
    [ColorCategory.borders]: {
        subtle: 6, // Subtle borders, separators
        DEFAULT: 7, // Element border, focus rings
        hover: 8, // Element hover border
    },
    [ColorCategory.accents]: {
        solid: 9, // Solid backgrounds
        'solid-hover': 10, // Hovered solid backgrounds */
    },
    [ColorCategory.text]: {
        // Very low-contrast text. 
        // Caution: this contrast does not meet accessiblity guidelines. 
        // Always check if you need to include a mitigating contrast-more 
        // style for users who need it.
        subtle: 9, 
        DEFAULT: 11, // Low-contrast text
        strong: 12, // High-contrast text
    },
};

Each of the steps in the color scale is then assigned a percentage that represents the mix of foreground and background. In light mode the background is white and the foreground is black. In dark mode these two extremes flip around. These values represent the amount of contrast between the color and the background, and the difference between steps represents the amount of contrast between steps.

/**
 * The mix of foreground and background for every step in a colour scale.
 * 0: 100% of the background color's lightness, = white in light mode
 * 1: 100% of the foreground color's lightness, = black in light mode
 */
export const colorMixMapping = {
    // bgs | components | borders | solid | text
    light: [
        0,
        0.02,
        0.03,
        0.05,
        0.07,
        0.1,
        0.15,
        0.2,
        0.5,
        0.55,
        0.6,
        1,
    ],
    dark: [
        0,
        0.03,
        0.08,
        0.1,
        0.13,
        0.15,
        0.2,
        0.25,
        0.5,
        0.55,
        0.75,
        1,
    ],
};

To do the actual palette generation, we take an input color defined as a hexadecimal RGB value and use a bunch of conversion functions to convert it to the OKLCH color space. To summarise a lot of color theory in a few sentences: some colors appear brighter than others to humans, and OKLCH is one of the color spaces that takes this perception difference into account. It's useful because it lets us do operations on a color in a way that is perceptually uniform – meaning that the output color's lightness (and thus the contrast, which is important for accessibility) will be the same no matter which color we put it in.

export function colorScale(hex, options) {
    // Convert from HEX to RGB to OKLCH
    const baseColor = rgbToOklch(
        hexToRgbArray(hex),
    );

    // Get our foreground and background values
    // depending on light or dark mode
    const foregroundColor = rgbToOklch(
        hexToRgbArray(foreground),
    );
    const backgroundColor = rgbToOklch(
        hexToRgbArray(background),
    );

    // Get our color steps, defined as a mix
    // from foreground to background
    const mapping = darkMode
        ? colorMixMapping.dark
        : colorMixMapping.light;

    const result = [];

    // Loop through every step of the scale
    // (=colorMixMapping, 12 steps)
    for (
        let index = 0;
        index < mapping.length;
        index++
    ) {
        // The target perceived lightness is a
        // simple ratio of foreground and background
        const targetL =
            foregroundColor.L * mapping[index] +
            backgroundColor.L * (1 - mapping[index]);

        // If the original color is close enough to
        // the target, we use it as step 9.
        if (
            index === 8 &&
            !mix &&
            Math.abs(baseColor.L - targetL) < 0.2
        ) {
            result.push(hex);
            continue;
        }

        // Background and borders should be
        // desaturated compared to the "solid" values
        const chromaRatio =
            index === 8 || index === 9
                ? 1
                : index * 0.05;

        // Calculate the actual color for this step
        const shade = {
            L: targetL, // Blend lightness
            C: baseColor.C * chromaRatio,
            H: baseColor.H, // Maintain the hue
        };

        // Convert back from OKLCH to RGB to HEX
        const newHex = rgbArrayToHex(
            oklchToRgb(shade),
        );

        result.push(newHex);
    }

    return result;
}

This function is slightly simplified to show the main logic. You can view the entire function on GitHub, alongside all the color space conversions.

Together with generating every color step, we also generate a contrast color for it using the Delta Phi Star contrast function, which compares perceived lightness values of black and white with the color step, and picks the one with the highest contrast. This contrast color can be used on top of the color step in question, which comes in useful for making text and icons readable on top of colored backgrounds like the header navigation.

function generateColorVariable(name, color, options) {
    // Set all the color steps of the scale
    const shades = colorScale(color, options)
        .map((shade, index) => [index + 1, shade]);

    return Object.entries(shades)
        .map(([key, value]) => {
            // We set the CSS variables to
            // space-separated RGB values instead of hex,
            // so that Tailwind can add opacity dynamically
            const rgbValue = hexToRgb(value);
            // Add contrast
            const contrastValue = hexToRgb(colorContrast(value));

            // Generate CSS variable definitions
            // --primary-1, --primary-2, ...
            return `--${name}-${key}: ${rgbValue}` +
                // --contrast-primary-1, --contrast-primary-2, ...
                `--contrast-${name}-${key}: ${contrastValue};`;
        })
        .join('\n');
}

Finally, upon generation of the site we run these functions on the site's chosen colors to generate all the scales we need: primary, tint, neutral, and semantic colors, along with the contrast colors:

<style>
    :root {
        ${generateColorVariable(
            'primary',
            customization.styling.primaryColor.light,
        )}
        ${generateColorVariable(
            'tint',
            tintColor ? tintColor.light : DEFAULT_TINT_COLOR,
        )}
        ${generateColorVariable(
            'neutral',
            DEFAULT_TINT_COLOR,
        )}
        ${generateColorVariable(
            'info',
            infoColor.light,
        )}
        ${generateColorVariable(
            'warning',
            warningColor.light,
        )}
        ${generateColorVariable(
            'danger',
            dangerColor.light,
        )}
        ${generateColorVariable(
            'success',
            successColor.light,
        )}
    }

    .dark {
        ${generateColorVariable(
            'primary',
            customization.styling.primaryColor.dark,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'tint',
            tintColor ? tintColor.dark : DEFAULT_TINT_COLOR,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'neutral',
            DEFAULT_TINT_COLOR,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'info',
            infoColor.dark,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'warning',
            warningColor.dark,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'danger',
            dangerColor.dark,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'success',
            successColor.dark,
            { darkMode: true },
        )}
    }
</style>

This writes each step of the color scale as a CSS variable into the :root of our site's DOM. Once available as CSS variables, we use Tailwind to apply these colors to the right elements. To make working with these palettes easier, we use a custom Tailwind configuration to make the right classes available at the right time.

Every numeric step of every palette (primary-1, primary-2, primary-3, ...) is added to Tailwind's theme.colors object. Specific subcategories of the scale are exposed to their corresponding attribute objects in tailwind.config.ts. As an example:

  • theme.backgroundColor gets the backgrounds, components, and accents subcategories, resulting in classes like bg-primary-base, bg-primary, and bg-primary-solid being available.

  • theme.borderColor gets the borders subcategory, resulting in classes like border-primary and bg-primary-hover being available.

In these examples bg-primary and border-primary map to very different colors, but are intuitive to write and make styling "correctly" in code more effortless.

Through a combination of color transformation in Typescript, CSS variables, and Tailwind configuration this system generates completely dynamic color palettes with consistent perceptual lightness and accessible contrast.

This is a tricky system to get exactly right for every single value, so I'm continuously making minute tweaks in response to customer feedback. For example, we now mix in a tiny bit of the primary color into the neutral and un-tinted palettes, to make the grey values look nice alongside the primary color. And in response to customers asking for true black backgrounds, the system now overrides the target lightness value if the supplied tint color has a lower value than the default minimum dark grey. If you notice anything off or you have feedback on this system, please reach out!

Revamping customization has been a tremendous effort for the whole team, and we're far from done. There are still many more styles to add, tweaks to be made, and entirely new themes to explore. If you're missing anything, if something isn't working right, or if you feel limited by what you can build, please reach out and let me know – I'm tweaking the system regularly and your feedback is instrumental in improving it.

There are now more ways than ever to get your docs site to look how you want. No matter which options you pick, the result will be beautiful, accessible, and uniquely yours.

→ Read more about our latest customization features

→ Register for the customization demo webinar

→ Get started with GitBook for free

Last week we covered the new customization features we launched for docs sites over the past few months. Since docs are a fundamental part of your product, it's important for the reading experience to be amazing, accessible, and aligned with your product. Customization is a key part of this. It's been a big focus for everyone at the company, and for me in particular.

👋 I'm Zeno, Design Engineer at GitBook. I've spearheaded the recent overhaul of our docs customization options, from its initial design to execution. The goal has been to turn a number of disparate features into a cohesive system that allows more room for expression. In this article I'll detail some of the thinking and building that went into this project, from high-level vision to technical rabbit holes I went down.

What's new?

As a quick recap, here are some of the features we've recently rolled out:

  • Evolving our existing header presets into four new site themes

  • A complete revamp of our color system, the introduction of tint color as a complement to the primary color, and recently semantic colors to fit your content to your brand's palette

  • Alternate component styles like sidebar styles, link styles, and hint blocks

  • Support for custom fonts

For more details about these features, you can read last week's post or check out the docs.

Themes and styles: one language for thousands of voices

GitBook's aim is to make it completely effortless to publish great-looking docs, so you can focus on writing great content instead. That means published sites need to look great out of the box. But your docs also need to speak with your product's voice, not GitBook's. Customization should thus be extensive (so you can truly make it yours) and super easy (without much investment from you). And this whole system needs to be cohesive: your docs should shine no matter what options you choose, which means every option needs to work well together with every other option.

So what options should we build, and how do they all relate to each other? How do we condense thousands of styles into a handful of choices? How do we build a language for thousands of voices to speak with?

To figure that out I benchmarked different docs sites, identified the common components, and grouped the different styles for each component into style "buckets". Based on this benchmark I made a baseline docs site layout and prototyped wildly different site designs on top of it that catered to the different vibes – from minimal to frivolous. I then started mixing and matching these designs into four distinct themes that set the creative direction and component styles that act as modifiers on top.

Systematic styling

Whereas the old header presets only influenced the header, themes work across the whole site: components can change their look depending on the chosen theme to fit in.

Four new themes

Styles act on top of themes, which means they need to work well on every single theme. To accomplish this, styles are chosen carefully to represent where to go, not how to get there.

An example. We introduced two sidebar styles to pick from: default has no background and filled adds a background. On the Clean theme, the filled background is darker than the site. But on the Muted theme, which itself adds a darker color to the site, the sidebar inverts its background to a lighter color for a stronger contrast.

Two different looks for the sidebar, on Clean and Muted

If we would have added explicit bg-dark and bg-light styles, the sidebar would look bad in some of these combinations. By clearly defining the intent of a style rather than its look, and then tweaking the style's implementation for every theme, we can make sure that every combination looks great.

Some styles make sense as a global option in the customization screen, while others work better on a per-component choice. They can be manual – like setting the card size on a Cards component – or automatically inferred – like how adding a heading to hint blocks makes its styling more prominent. The aim is to increase the total number of options without overwhelming choice.

By adding a heading block inside hints, they automatically get a new alternative style.

Since launch we've already added a bunch of new styles, most of which have come from customers asking for something that our styles simply didn't cover yet. For example, the new link styles and alternative hint block styling were directly inspired by a customer request. If you have a suggestion for a style we don't cover, please reach out and let us know!

Link styles

🐰🛠️ Technical rabbit hole: Tailwind custom variants

Before this project, we passed a customization object through our React tree to check for values and apply colors, but this would be unfeasible if we wanted to influence components throughout the whole site. So instead, to make these permutations easy to work with, I heavily utilised custom variants in Tailwind. We now dynamically generate them in our Tailwind config:

const config: Config = {
    plugins: [
        plugin(({ addVariant }) => {
            const customisationVariants = {
                sidebar: [
                    'sidebar-default',
                    'sidebar-filled',
                ], // Sidebar styles
                list: [
                    'sidebar-list-default',
                    'sidebar-list-pill',
                    'sidebar-list-line',
                ], // List styles
                tint: [
                    'tint',
                    'no-tint',
                ], // Tint colours
                theme: [
                    'theme-clean',
                    'theme-muted',
                    'theme-bold',
                    'theme-gradient',
                ], // Themes
                corner: [
                    'straight-corners',
                ], // Corner styles
                links: [
                    'links-default',
                    'links-accent',
                ], // Link styles
            };

            for (const [category, variants] of Object.entries(
                customisationVariants,
            )) {
                for (const variant of variants) {
                    addVariant(variant, `html.${variant} &`);
                }
            }
        }),
    ],
};

Upon launch, the site's theme and any global styles are appended as classes to the <html> tag. Through custom variants, we can write conditional CSS like theme-clean:bg-transparent theme-muted:bg-muted anywhere in our codebase, and it will conditionally apply these styles to the right themes.

One caveat of variants is that nesting them is not always straightforward or possible. Checking for the aforementioned classes on the <html> results in Tailwind statements like:

addVariant('theme-clean', 'html.theme-clean &');
addVariant('tint', 'html.tint &');

But let's say you want to conditionally style something for only the tinted version of the Clean theme using a statement like theme-clean:tint:bg-white. That statement would get interpreted as the following CSS:

html.theme-clean html.tint & {
    background: #fff /* Won't work */
}

This CSS selector would never select anything, because the html tag isn't nested. Instead we need a CSS selector that targets a double class, like:

html.theme-clean.tint & { /* Check for both classes to be present */
    background: #fff
}

But you can't express this with Tailwind's addVariant() function (or at least I haven't found a way). So instead, we generate "combi-variants" for commonly used combinations with some extra code:

for (const [category, variants] of Object.entries(
    customisationVariants,
)) {
    for (const variant of variants) {
        addVariant(
            variant,
            `html.${variant} &`,
        );
        // Same as before, generates e.g.
        // theme-clean, theme-muted, ...
        if (category === 'tint') {
            for (const themeVariant of
                customisationVariants.theme
            ) {
                addVariant(
                    `${themeVariant}-${variant}`,
                    // e.g. theme-clean-tint, ...
                    `html.${variant}.${themeVariant} &`,
                    // e.g. html.theme-clean.tint, ...
                );
            }
        }
    }
}

This results in three nice variants to work with in our codebase.

  • theme-clean: targets Clean no matter the tint color (html.theme-clean &)

  • theme-clean-tint: targets Clean with tint color set (html.theme-clean.tint &)

  • theme-clean-no-tint: targets Clean with no tint color set (html.theme-clean.no-tint &)

You can see the final Tailwind config and custom variants yourself on GitHub.

Colors and fonts: making it work for everyone

So with careful calibration we can make every theme & style combination look great, but there are two big modifiers left: colors and fonts. These are perhaps the most important things to make your docs site feel in line with your product, and they're also the most tricky since there are infinite possibilities for both. There's a tricky balance to be struck between form and function, as well as setting good defaults.

Thoughtful typography

We curate a short list of popular fonts to use on your site, with the typographic workhorse Inter as the default. The benefit of a curated list is that we can make stylistic overrides where needed, for example to set ligatures on fonts like Lato to the most appropriate versions.

Custom fonts place the responsibility for readable type mostly on the site publisher, but we can still guide them to make the right choices. For instance, we require at least two weights of a custom font because the front-end uses different font weights to distinguish important information from asides.

The font upload dialog helps set the required styles to make your custom font look good

​​As part of building custom fonts we also reevaluated the typographic lockups on published content, and spent a lot of time on fetching and rendering the font as early as possible to prevent flashes of unstyled text. One great example of custom fonts in action is on the newly-launched NVIDIA Run:ai docs.

Building color palettes

People care a lot about color. Functionally color is one of the most important factors for text readability, together with text size. Emotionally colors are one of your brand's most important and recognisable elements. For docs sites this means we need to represent any user-supplied color faithfully, while keeping the end result beautiful and readable for a large, diverse audience.

Before this project, site authors could supply one primary color that determined the look of many elements on the site, from links to backgrounds. Apart from that color, the system used a collection of different grays and default Tailwind values to style published content. It was a bit of a hodgepodge of colors and greys, and none of it was applied consistently across components.

The new system consists of two main colors: the primary color and the tint color, together with a few auxiliary colors for specific scenarios.

  • The primary color is supposed to be the main brand color. It colors primary navigational and interactive elements like buttons, links, and active tabs.

  • The tint color acts as a global modifier for nearly every part of the site, like the site background and components. In the Bold theme it colors the header, in Gradient it colors the main gradient. Nearly every color on the site uses a tint-* class. By default, the tint color is a neutral gray with a tiny bit of the primary color mixed in, resulting in a neutral-looking site. By picking a custom tint color, site authors can dramatically re-color their whole site while keeping their primary color for links.

  • Semantic colors are used for status messages across the site, like the different types of hint blocks, or the required text in API blocks. There are four semantic colors: info, success, warning, and danger. These colors can be overridden by site authors. Semantic colors were directly inspired by customer feedback about the arbitrary blue and green we used for our hint blocks before.

  • The neutral scale is a simple set of neutral grays that can be used for any neutral elements that should never be colored, like overlays or close buttons.

For each of these colors we dynamically generate a color palette consisting of 12 colors from very dark to very light. Each step of the scale is used for a specific purpose, like backgrounds, borders, or text. By picking an appropriate brightness (or perceptual lightness to be more precise, detailed below) for each step, we get a scale that has strong contrast with other steps, no matter which color it is based on.

Example of a tint color scale and where we use the steps of the scale

Form follows function

The generated docs sites are designed to be as accessible and readable as possible (surpassing WCAG 2.1 AA or AAA contrast levels) by default, while staying true to the site author's chosen brand colors where it matters. For example, links and buttons use the exact primary color the site author has specified, even if it doesn't meet the contrast guidelines.

To keep content accessible, we build in accessibility checks and alternate styles. When a visitor's browser requests high-contrast styles (set at the OS level and detected using the prefer-contrast CSS media feature), we always render a high-contrast color or add a differentation that doesn't rely on color. This results in a pretty different docs experience for those who need it.

Example of high-contrast styles: thicker lines and contrasting text colors

🐰🛠️ Technical rabbit hole: dynamic color palettes with perceptual lightness

The color scales used in our system are strongly inspired by the Radix color scale and use the same semantic steps, but the palette generation is completely custom. Radix consists of a number of handcrafted and manually tweaked color scales. Although you can generate a custom scale, it essentially returns the closest estimated pre-made scale, with some steps changed to the input color. Our system generates a completely dynamic palette based on an input color.

An example of a complete generated site palette. Step 9 is the originally supplied color for each scale.

Each color scale consists of 12 steps. For convenience it is split up into categories that are used in selectively generating Tailwind utility classes (more about that later). Each category has named colors within it, and each color references a step in the scale from 1 to 12. Some steps can become multiple named colors, e.g. accents.solid and text.subtle both reference step 9 of the scale.

export const scale: Record<ColorCategory, ColorSubScale> = {
    [ColorCategory.backgrounds]: {
        base: 1, // Base background
        subtle: 2, // Accent background
    },
    [ColorCategory.components]: {
        DEFAULT: 3, // Component background
        hover: 4, // Component hover background
        active: 5, // Component active background
    },
    [ColorCategory.borders]: {
        subtle: 6, // Subtle borders, separators
        DEFAULT: 7, // Element border, focus rings
        hover: 8, // Element hover border
    },
    [ColorCategory.accents]: {
        solid: 9, // Solid backgrounds
        'solid-hover': 10, // Hovered solid backgrounds */
    },
    [ColorCategory.text]: {
        // Very low-contrast text. 
        // Caution: this contrast does not meet accessiblity guidelines. 
        // Always check if you need to include a mitigating contrast-more 
        // style for users who need it.
        subtle: 9, 
        DEFAULT: 11, // Low-contrast text
        strong: 12, // High-contrast text
    },
};

Each of the steps in the color scale is then assigned a percentage that represents the mix of foreground and background. In light mode the background is white and the foreground is black. In dark mode these two extremes flip around. These values represent the amount of contrast between the color and the background, and the difference between steps represents the amount of contrast between steps.

/**
 * The mix of foreground and background for every step in a colour scale.
 * 0: 100% of the background color's lightness, = white in light mode
 * 1: 100% of the foreground color's lightness, = black in light mode
 */
export const colorMixMapping = {
    // bgs | components | borders | solid | text
    light: [
        0,
        0.02,
        0.03,
        0.05,
        0.07,
        0.1,
        0.15,
        0.2,
        0.5,
        0.55,
        0.6,
        1,
    ],
    dark: [
        0,
        0.03,
        0.08,
        0.1,
        0.13,
        0.15,
        0.2,
        0.25,
        0.5,
        0.55,
        0.75,
        1,
    ],
};

To do the actual palette generation, we take an input color defined as a hexadecimal RGB value and use a bunch of conversion functions to convert it to the OKLCH color space. To summarise a lot of color theory in a few sentences: some colors appear brighter than others to humans, and OKLCH is one of the color spaces that takes this perception difference into account. It's useful because it lets us do operations on a color in a way that is perceptually uniform – meaning that the output color's lightness (and thus the contrast, which is important for accessibility) will be the same no matter which color we put it in.

export function colorScale(hex, options) {
    // Convert from HEX to RGB to OKLCH
    const baseColor = rgbToOklch(
        hexToRgbArray(hex),
    );

    // Get our foreground and background values
    // depending on light or dark mode
    const foregroundColor = rgbToOklch(
        hexToRgbArray(foreground),
    );
    const backgroundColor = rgbToOklch(
        hexToRgbArray(background),
    );

    // Get our color steps, defined as a mix
    // from foreground to background
    const mapping = darkMode
        ? colorMixMapping.dark
        : colorMixMapping.light;

    const result = [];

    // Loop through every step of the scale
    // (=colorMixMapping, 12 steps)
    for (
        let index = 0;
        index < mapping.length;
        index++
    ) {
        // The target perceived lightness is a
        // simple ratio of foreground and background
        const targetL =
            foregroundColor.L * mapping[index] +
            backgroundColor.L * (1 - mapping[index]);

        // If the original color is close enough to
        // the target, we use it as step 9.
        if (
            index === 8 &&
            !mix &&
            Math.abs(baseColor.L - targetL) < 0.2
        ) {
            result.push(hex);
            continue;
        }

        // Background and borders should be
        // desaturated compared to the "solid" values
        const chromaRatio =
            index === 8 || index === 9
                ? 1
                : index * 0.05;

        // Calculate the actual color for this step
        const shade = {
            L: targetL, // Blend lightness
            C: baseColor.C * chromaRatio,
            H: baseColor.H, // Maintain the hue
        };

        // Convert back from OKLCH to RGB to HEX
        const newHex = rgbArrayToHex(
            oklchToRgb(shade),
        );

        result.push(newHex);
    }

    return result;
}

This function is slightly simplified to show the main logic. You can view the entire function on GitHub, alongside all the color space conversions.

Together with generating every color step, we also generate a contrast color for it using the Delta Phi Star contrast function, which compares perceived lightness values of black and white with the color step, and picks the one with the highest contrast. This contrast color can be used on top of the color step in question, which comes in useful for making text and icons readable on top of colored backgrounds like the header navigation.

function generateColorVariable(name, color, options) {
    // Set all the color steps of the scale
    const shades = colorScale(color, options)
        .map((shade, index) => [index + 1, shade]);

    return Object.entries(shades)
        .map(([key, value]) => {
            // We set the CSS variables to
            // space-separated RGB values instead of hex,
            // so that Tailwind can add opacity dynamically
            const rgbValue = hexToRgb(value);
            // Add contrast
            const contrastValue = hexToRgb(colorContrast(value));

            // Generate CSS variable definitions
            // --primary-1, --primary-2, ...
            return `--${name}-${key}: ${rgbValue}` +
                // --contrast-primary-1, --contrast-primary-2, ...
                `--contrast-${name}-${key}: ${contrastValue};`;
        })
        .join('\n');
}

Finally, upon generation of the site we run these functions on the site's chosen colors to generate all the scales we need: primary, tint, neutral, and semantic colors, along with the contrast colors:

<style>
    :root {
        ${generateColorVariable(
            'primary',
            customization.styling.primaryColor.light,
        )}
        ${generateColorVariable(
            'tint',
            tintColor ? tintColor.light : DEFAULT_TINT_COLOR,
        )}
        ${generateColorVariable(
            'neutral',
            DEFAULT_TINT_COLOR,
        )}
        ${generateColorVariable(
            'info',
            infoColor.light,
        )}
        ${generateColorVariable(
            'warning',
            warningColor.light,
        )}
        ${generateColorVariable(
            'danger',
            dangerColor.light,
        )}
        ${generateColorVariable(
            'success',
            successColor.light,
        )}
    }

    .dark {
        ${generateColorVariable(
            'primary',
            customization.styling.primaryColor.dark,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'tint',
            tintColor ? tintColor.dark : DEFAULT_TINT_COLOR,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'neutral',
            DEFAULT_TINT_COLOR,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'info',
            infoColor.dark,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'warning',
            warningColor.dark,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'danger',
            dangerColor.dark,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'success',
            successColor.dark,
            { darkMode: true },
        )}
    }
</style>

This writes each step of the color scale as a CSS variable into the :root of our site's DOM. Once available as CSS variables, we use Tailwind to apply these colors to the right elements. To make working with these palettes easier, we use a custom Tailwind configuration to make the right classes available at the right time.

Every numeric step of every palette (primary-1, primary-2, primary-3, ...) is added to Tailwind's theme.colors object. Specific subcategories of the scale are exposed to their corresponding attribute objects in tailwind.config.ts. As an example:

  • theme.backgroundColor gets the backgrounds, components, and accents subcategories, resulting in classes like bg-primary-base, bg-primary, and bg-primary-solid being available.

  • theme.borderColor gets the borders subcategory, resulting in classes like border-primary and bg-primary-hover being available.

In these examples bg-primary and border-primary map to very different colors, but are intuitive to write and make styling "correctly" in code more effortless.

Through a combination of color transformation in Typescript, CSS variables, and Tailwind configuration this system generates completely dynamic color palettes with consistent perceptual lightness and accessible contrast.

This is a tricky system to get exactly right for every single value, so I'm continuously making minute tweaks in response to customer feedback. For example, we now mix in a tiny bit of the primary color into the neutral and un-tinted palettes, to make the grey values look nice alongside the primary color. And in response to customers asking for true black backgrounds, the system now overrides the target lightness value if the supplied tint color has a lower value than the default minimum dark grey. If you notice anything off or you have feedback on this system, please reach out!

Revamping customization has been a tremendous effort for the whole team, and we're far from done. There are still many more styles to add, tweaks to be made, and entirely new themes to explore. If you're missing anything, if something isn't working right, or if you feel limited by what you can build, please reach out and let me know – I'm tweaking the system regularly and your feedback is instrumental in improving it.

There are now more ways than ever to get your docs site to look how you want. No matter which options you pick, the result will be beautiful, accessible, and uniquely yours.

→ Read more about our latest customization features

→ Register for the customization demo webinar

→ Get started with GitBook for free

Last week we covered the new customization features we launched for docs sites over the past few months. Since docs are a fundamental part of your product, it's important for the reading experience to be amazing, accessible, and aligned with your product. Customization is a key part of this. It's been a big focus for everyone at the company, and for me in particular.

👋 I'm Zeno, Design Engineer at GitBook. I've spearheaded the recent overhaul of our docs customization options, from its initial design to execution. The goal has been to turn a number of disparate features into a cohesive system that allows more room for expression. In this article I'll detail some of the thinking and building that went into this project, from high-level vision to technical rabbit holes I went down.

What's new?

As a quick recap, here are some of the features we've recently rolled out:

  • Evolving our existing header presets into four new site themes

  • A complete revamp of our color system, the introduction of tint color as a complement to the primary color, and recently semantic colors to fit your content to your brand's palette

  • Alternate component styles like sidebar styles, link styles, and hint blocks

  • Support for custom fonts

For more details about these features, you can read last week's post or check out the docs.

Themes and styles: one language for thousands of voices

GitBook's aim is to make it completely effortless to publish great-looking docs, so you can focus on writing great content instead. That means published sites need to look great out of the box. But your docs also need to speak with your product's voice, not GitBook's. Customization should thus be extensive (so you can truly make it yours) and super easy (without much investment from you). And this whole system needs to be cohesive: your docs should shine no matter what options you choose, which means every option needs to work well together with every other option.

So what options should we build, and how do they all relate to each other? How do we condense thousands of styles into a handful of choices? How do we build a language for thousands of voices to speak with?

To figure that out I benchmarked different docs sites, identified the common components, and grouped the different styles for each component into style "buckets". Based on this benchmark I made a baseline docs site layout and prototyped wildly different site designs on top of it that catered to the different vibes – from minimal to frivolous. I then started mixing and matching these designs into four distinct themes that set the creative direction and component styles that act as modifiers on top.

Systematic styling

Whereas the old header presets only influenced the header, themes work across the whole site: components can change their look depending on the chosen theme to fit in.

Four new themes

Styles act on top of themes, which means they need to work well on every single theme. To accomplish this, styles are chosen carefully to represent where to go, not how to get there.

An example. We introduced two sidebar styles to pick from: default has no background and filled adds a background. On the Clean theme, the filled background is darker than the site. But on the Muted theme, which itself adds a darker color to the site, the sidebar inverts its background to a lighter color for a stronger contrast.

Two different looks for the sidebar, on Clean and Muted

If we would have added explicit bg-dark and bg-light styles, the sidebar would look bad in some of these combinations. By clearly defining the intent of a style rather than its look, and then tweaking the style's implementation for every theme, we can make sure that every combination looks great.

Some styles make sense as a global option in the customization screen, while others work better on a per-component choice. They can be manual – like setting the card size on a Cards component – or automatically inferred – like how adding a heading to hint blocks makes its styling more prominent. The aim is to increase the total number of options without overwhelming choice.

By adding a heading block inside hints, they automatically get a new alternative style.

Since launch we've already added a bunch of new styles, most of which have come from customers asking for something that our styles simply didn't cover yet. For example, the new link styles and alternative hint block styling were directly inspired by a customer request. If you have a suggestion for a style we don't cover, please reach out and let us know!

Link styles

🐰🛠️ Technical rabbit hole: Tailwind custom variants

Before this project, we passed a customization object through our React tree to check for values and apply colors, but this would be unfeasible if we wanted to influence components throughout the whole site. So instead, to make these permutations easy to work with, I heavily utilised custom variants in Tailwind. We now dynamically generate them in our Tailwind config:

const config: Config = {
    plugins: [
        plugin(({ addVariant }) => {
            const customisationVariants = {
                sidebar: [
                    'sidebar-default',
                    'sidebar-filled',
                ], // Sidebar styles
                list: [
                    'sidebar-list-default',
                    'sidebar-list-pill',
                    'sidebar-list-line',
                ], // List styles
                tint: [
                    'tint',
                    'no-tint',
                ], // Tint colours
                theme: [
                    'theme-clean',
                    'theme-muted',
                    'theme-bold',
                    'theme-gradient',
                ], // Themes
                corner: [
                    'straight-corners',
                ], // Corner styles
                links: [
                    'links-default',
                    'links-accent',
                ], // Link styles
            };

            for (const [category, variants] of Object.entries(
                customisationVariants,
            )) {
                for (const variant of variants) {
                    addVariant(variant, `html.${variant} &`);
                }
            }
        }),
    ],
};

Upon launch, the site's theme and any global styles are appended as classes to the <html> tag. Through custom variants, we can write conditional CSS like theme-clean:bg-transparent theme-muted:bg-muted anywhere in our codebase, and it will conditionally apply these styles to the right themes.

One caveat of variants is that nesting them is not always straightforward or possible. Checking for the aforementioned classes on the <html> results in Tailwind statements like:

addVariant('theme-clean', 'html.theme-clean &');
addVariant('tint', 'html.tint &');

But let's say you want to conditionally style something for only the tinted version of the Clean theme using a statement like theme-clean:tint:bg-white. That statement would get interpreted as the following CSS:

html.theme-clean html.tint & {
    background: #fff /* Won't work */
}

This CSS selector would never select anything, because the html tag isn't nested. Instead we need a CSS selector that targets a double class, like:

html.theme-clean.tint & { /* Check for both classes to be present */
    background: #fff
}

But you can't express this with Tailwind's addVariant() function (or at least I haven't found a way). So instead, we generate "combi-variants" for commonly used combinations with some extra code:

for (const [category, variants] of Object.entries(
    customisationVariants,
)) {
    for (const variant of variants) {
        addVariant(
            variant,
            `html.${variant} &`,
        );
        // Same as before, generates e.g.
        // theme-clean, theme-muted, ...
        if (category === 'tint') {
            for (const themeVariant of
                customisationVariants.theme
            ) {
                addVariant(
                    `${themeVariant}-${variant}`,
                    // e.g. theme-clean-tint, ...
                    `html.${variant}.${themeVariant} &`,
                    // e.g. html.theme-clean.tint, ...
                );
            }
        }
    }
}

This results in three nice variants to work with in our codebase.

  • theme-clean: targets Clean no matter the tint color (html.theme-clean &)

  • theme-clean-tint: targets Clean with tint color set (html.theme-clean.tint &)

  • theme-clean-no-tint: targets Clean with no tint color set (html.theme-clean.no-tint &)

You can see the final Tailwind config and custom variants yourself on GitHub.

Colors and fonts: making it work for everyone

So with careful calibration we can make every theme & style combination look great, but there are two big modifiers left: colors and fonts. These are perhaps the most important things to make your docs site feel in line with your product, and they're also the most tricky since there are infinite possibilities for both. There's a tricky balance to be struck between form and function, as well as setting good defaults.

Thoughtful typography

We curate a short list of popular fonts to use on your site, with the typographic workhorse Inter as the default. The benefit of a curated list is that we can make stylistic overrides where needed, for example to set ligatures on fonts like Lato to the most appropriate versions.

Custom fonts place the responsibility for readable type mostly on the site publisher, but we can still guide them to make the right choices. For instance, we require at least two weights of a custom font because the front-end uses different font weights to distinguish important information from asides.

The font upload dialog helps set the required styles to make your custom font look good

​​As part of building custom fonts we also reevaluated the typographic lockups on published content, and spent a lot of time on fetching and rendering the font as early as possible to prevent flashes of unstyled text. One great example of custom fonts in action is on the newly-launched NVIDIA Run:ai docs.

Building color palettes

People care a lot about color. Functionally color is one of the most important factors for text readability, together with text size. Emotionally colors are one of your brand's most important and recognisable elements. For docs sites this means we need to represent any user-supplied color faithfully, while keeping the end result beautiful and readable for a large, diverse audience.

Before this project, site authors could supply one primary color that determined the look of many elements on the site, from links to backgrounds. Apart from that color, the system used a collection of different grays and default Tailwind values to style published content. It was a bit of a hodgepodge of colors and greys, and none of it was applied consistently across components.

The new system consists of two main colors: the primary color and the tint color, together with a few auxiliary colors for specific scenarios.

  • The primary color is supposed to be the main brand color. It colors primary navigational and interactive elements like buttons, links, and active tabs.

  • The tint color acts as a global modifier for nearly every part of the site, like the site background and components. In the Bold theme it colors the header, in Gradient it colors the main gradient. Nearly every color on the site uses a tint-* class. By default, the tint color is a neutral gray with a tiny bit of the primary color mixed in, resulting in a neutral-looking site. By picking a custom tint color, site authors can dramatically re-color their whole site while keeping their primary color for links.

  • Semantic colors are used for status messages across the site, like the different types of hint blocks, or the required text in API blocks. There are four semantic colors: info, success, warning, and danger. These colors can be overridden by site authors. Semantic colors were directly inspired by customer feedback about the arbitrary blue and green we used for our hint blocks before.

  • The neutral scale is a simple set of neutral grays that can be used for any neutral elements that should never be colored, like overlays or close buttons.

For each of these colors we dynamically generate a color palette consisting of 12 colors from very dark to very light. Each step of the scale is used for a specific purpose, like backgrounds, borders, or text. By picking an appropriate brightness (or perceptual lightness to be more precise, detailed below) for each step, we get a scale that has strong contrast with other steps, no matter which color it is based on.

Example of a tint color scale and where we use the steps of the scale

Form follows function

The generated docs sites are designed to be as accessible and readable as possible (surpassing WCAG 2.1 AA or AAA contrast levels) by default, while staying true to the site author's chosen brand colors where it matters. For example, links and buttons use the exact primary color the site author has specified, even if it doesn't meet the contrast guidelines.

To keep content accessible, we build in accessibility checks and alternate styles. When a visitor's browser requests high-contrast styles (set at the OS level and detected using the prefer-contrast CSS media feature), we always render a high-contrast color or add a differentation that doesn't rely on color. This results in a pretty different docs experience for those who need it.

Example of high-contrast styles: thicker lines and contrasting text colors

🐰🛠️ Technical rabbit hole: dynamic color palettes with perceptual lightness

The color scales used in our system are strongly inspired by the Radix color scale and use the same semantic steps, but the palette generation is completely custom. Radix consists of a number of handcrafted and manually tweaked color scales. Although you can generate a custom scale, it essentially returns the closest estimated pre-made scale, with some steps changed to the input color. Our system generates a completely dynamic palette based on an input color.

An example of a complete generated site palette. Step 9 is the originally supplied color for each scale.

Each color scale consists of 12 steps. For convenience it is split up into categories that are used in selectively generating Tailwind utility classes (more about that later). Each category has named colors within it, and each color references a step in the scale from 1 to 12. Some steps can become multiple named colors, e.g. accents.solid and text.subtle both reference step 9 of the scale.

export const scale: Record<ColorCategory, ColorSubScale> = {
    [ColorCategory.backgrounds]: {
        base: 1, // Base background
        subtle: 2, // Accent background
    },
    [ColorCategory.components]: {
        DEFAULT: 3, // Component background
        hover: 4, // Component hover background
        active: 5, // Component active background
    },
    [ColorCategory.borders]: {
        subtle: 6, // Subtle borders, separators
        DEFAULT: 7, // Element border, focus rings
        hover: 8, // Element hover border
    },
    [ColorCategory.accents]: {
        solid: 9, // Solid backgrounds
        'solid-hover': 10, // Hovered solid backgrounds */
    },
    [ColorCategory.text]: {
        // Very low-contrast text. 
        // Caution: this contrast does not meet accessiblity guidelines. 
        // Always check if you need to include a mitigating contrast-more 
        // style for users who need it.
        subtle: 9, 
        DEFAULT: 11, // Low-contrast text
        strong: 12, // High-contrast text
    },
};

Each of the steps in the color scale is then assigned a percentage that represents the mix of foreground and background. In light mode the background is white and the foreground is black. In dark mode these two extremes flip around. These values represent the amount of contrast between the color and the background, and the difference between steps represents the amount of contrast between steps.

/**
 * The mix of foreground and background for every step in a colour scale.
 * 0: 100% of the background color's lightness, = white in light mode
 * 1: 100% of the foreground color's lightness, = black in light mode
 */
export const colorMixMapping = {
    // bgs | components | borders | solid | text
    light: [
        0,
        0.02,
        0.03,
        0.05,
        0.07,
        0.1,
        0.15,
        0.2,
        0.5,
        0.55,
        0.6,
        1,
    ],
    dark: [
        0,
        0.03,
        0.08,
        0.1,
        0.13,
        0.15,
        0.2,
        0.25,
        0.5,
        0.55,
        0.75,
        1,
    ],
};

To do the actual palette generation, we take an input color defined as a hexadecimal RGB value and use a bunch of conversion functions to convert it to the OKLCH color space. To summarise a lot of color theory in a few sentences: some colors appear brighter than others to humans, and OKLCH is one of the color spaces that takes this perception difference into account. It's useful because it lets us do operations on a color in a way that is perceptually uniform – meaning that the output color's lightness (and thus the contrast, which is important for accessibility) will be the same no matter which color we put it in.

export function colorScale(hex, options) {
    // Convert from HEX to RGB to OKLCH
    const baseColor = rgbToOklch(
        hexToRgbArray(hex),
    );

    // Get our foreground and background values
    // depending on light or dark mode
    const foregroundColor = rgbToOklch(
        hexToRgbArray(foreground),
    );
    const backgroundColor = rgbToOklch(
        hexToRgbArray(background),
    );

    // Get our color steps, defined as a mix
    // from foreground to background
    const mapping = darkMode
        ? colorMixMapping.dark
        : colorMixMapping.light;

    const result = [];

    // Loop through every step of the scale
    // (=colorMixMapping, 12 steps)
    for (
        let index = 0;
        index < mapping.length;
        index++
    ) {
        // The target perceived lightness is a
        // simple ratio of foreground and background
        const targetL =
            foregroundColor.L * mapping[index] +
            backgroundColor.L * (1 - mapping[index]);

        // If the original color is close enough to
        // the target, we use it as step 9.
        if (
            index === 8 &&
            !mix &&
            Math.abs(baseColor.L - targetL) < 0.2
        ) {
            result.push(hex);
            continue;
        }

        // Background and borders should be
        // desaturated compared to the "solid" values
        const chromaRatio =
            index === 8 || index === 9
                ? 1
                : index * 0.05;

        // Calculate the actual color for this step
        const shade = {
            L: targetL, // Blend lightness
            C: baseColor.C * chromaRatio,
            H: baseColor.H, // Maintain the hue
        };

        // Convert back from OKLCH to RGB to HEX
        const newHex = rgbArrayToHex(
            oklchToRgb(shade),
        );

        result.push(newHex);
    }

    return result;
}

This function is slightly simplified to show the main logic. You can view the entire function on GitHub, alongside all the color space conversions.

Together with generating every color step, we also generate a contrast color for it using the Delta Phi Star contrast function, which compares perceived lightness values of black and white with the color step, and picks the one with the highest contrast. This contrast color can be used on top of the color step in question, which comes in useful for making text and icons readable on top of colored backgrounds like the header navigation.

function generateColorVariable(name, color, options) {
    // Set all the color steps of the scale
    const shades = colorScale(color, options)
        .map((shade, index) => [index + 1, shade]);

    return Object.entries(shades)
        .map(([key, value]) => {
            // We set the CSS variables to
            // space-separated RGB values instead of hex,
            // so that Tailwind can add opacity dynamically
            const rgbValue = hexToRgb(value);
            // Add contrast
            const contrastValue = hexToRgb(colorContrast(value));

            // Generate CSS variable definitions
            // --primary-1, --primary-2, ...
            return `--${name}-${key}: ${rgbValue}` +
                // --contrast-primary-1, --contrast-primary-2, ...
                `--contrast-${name}-${key}: ${contrastValue};`;
        })
        .join('\n');
}

Finally, upon generation of the site we run these functions on the site's chosen colors to generate all the scales we need: primary, tint, neutral, and semantic colors, along with the contrast colors:

<style>
    :root {
        ${generateColorVariable(
            'primary',
            customization.styling.primaryColor.light,
        )}
        ${generateColorVariable(
            'tint',
            tintColor ? tintColor.light : DEFAULT_TINT_COLOR,
        )}
        ${generateColorVariable(
            'neutral',
            DEFAULT_TINT_COLOR,
        )}
        ${generateColorVariable(
            'info',
            infoColor.light,
        )}
        ${generateColorVariable(
            'warning',
            warningColor.light,
        )}
        ${generateColorVariable(
            'danger',
            dangerColor.light,
        )}
        ${generateColorVariable(
            'success',
            successColor.light,
        )}
    }

    .dark {
        ${generateColorVariable(
            'primary',
            customization.styling.primaryColor.dark,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'tint',
            tintColor ? tintColor.dark : DEFAULT_TINT_COLOR,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'neutral',
            DEFAULT_TINT_COLOR,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'info',
            infoColor.dark,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'warning',
            warningColor.dark,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'danger',
            dangerColor.dark,
            { darkMode: true },
        )}
        ${generateColorVariable(
            'success',
            successColor.dark,
            { darkMode: true },
        )}
    }
</style>

This writes each step of the color scale as a CSS variable into the :root of our site's DOM. Once available as CSS variables, we use Tailwind to apply these colors to the right elements. To make working with these palettes easier, we use a custom Tailwind configuration to make the right classes available at the right time.

Every numeric step of every palette (primary-1, primary-2, primary-3, ...) is added to Tailwind's theme.colors object. Specific subcategories of the scale are exposed to their corresponding attribute objects in tailwind.config.ts. As an example:

  • theme.backgroundColor gets the backgrounds, components, and accents subcategories, resulting in classes like bg-primary-base, bg-primary, and bg-primary-solid being available.

  • theme.borderColor gets the borders subcategory, resulting in classes like border-primary and bg-primary-hover being available.

In these examples bg-primary and border-primary map to very different colors, but are intuitive to write and make styling "correctly" in code more effortless.

Through a combination of color transformation in Typescript, CSS variables, and Tailwind configuration this system generates completely dynamic color palettes with consistent perceptual lightness and accessible contrast.

This is a tricky system to get exactly right for every single value, so I'm continuously making minute tweaks in response to customer feedback. For example, we now mix in a tiny bit of the primary color into the neutral and un-tinted palettes, to make the grey values look nice alongside the primary color. And in response to customers asking for true black backgrounds, the system now overrides the target lightness value if the supplied tint color has a lower value than the default minimum dark grey. If you notice anything off or you have feedback on this system, please reach out!

Revamping customization has been a tremendous effort for the whole team, and we're far from done. There are still many more styles to add, tweaks to be made, and entirely new themes to explore. If you're missing anything, if something isn't working right, or if you feel limited by what you can build, please reach out and let me know – I'm tweaking the system regularly and your feedback is instrumental in improving it.

There are now more ways than ever to get your docs site to look how you want. No matter which options you pick, the result will be beautiful, accessible, and uniquely yours.

→ Read more about our latest customization features

→ Register for the customization demo webinar

→ Get started with GitBook for free

Get the GitBook newsletter

Get the latest product news, useful resources and more in your inbox. 130k+ people read it every month.

Email

Similar posts

Get started for free

Play around with GitBook and set up your docs for free. Add your team and pay when you’re ready.

Trusted by leading technical product teams

Get started for free

Play around with GitBook and set up your docs for free. Add your team and pay when you’re ready.

Trusted by leading technical product teams

Get started for free

Play around with GitBook and set up your docs for free. Add your team and pay when you’re ready.

Trusted by leading technical product teams