DEV Community

Cover image for Styling and Color
ShaynaProductions
ShaynaProductions

Posted on

Styling and Color

Prologue

A while ago, I decided to develop a fully accessible main navigation component in React and write a series of articles documenting the steps it took to create a non-trivial accessible component.

In my last design article, I demonstrated how structural HTML and nested CSS are natural allies, allowing for concise layout code.

With a layout now applied, focus can shift to adding color to the component, with all that entails.


Note: This article is one of a series demonstrating building a React navigational component from scratch while considering accessibility through the process. The articles are accompanied by a GitHub repository with releases tied to one or more articles; each builds on the previous one until a fully implemented navigation component is complete.

Each release and its associated tag contain fully runnable code for the article. The code discussed in this article is available in the release. and may be downloaded at release 0.7.0. Links in the article will take you to the proper file in the tagged GitHub Repository.

While code examples are written in JavaScript for brevity, all actual code is written in Typescript and targets React 19.x, all while using vanilla CSS. Examples use Next.js v16.x, which is not required to run the navigation component.

The design requirements for this release are available.


Content Links

Introduction

In an earlier article, I went through the process of styling a layout on an uncontrolled horizontal navigation component. With that now complete, focus can start on adding color to the component.

Up until this point, I've managed to skirt the issue of accessibility by focusing on perceivable developing for peripherals, screen readers and screens. As I mentioned in an earlier article, The Modality of Accessibility I've found it's easier to focus on peripheral development than try to invoke compassion.

But when it comes to design, dispassion disappears. Adding color to a component means remembering that not everyone has the same visual acuity as the designer. We need to be comfortable with the knowledge that not everyone will have the same experience viewing what's on the screen, and what's more, we shouldn't expect them to.

When it comes to colors, the contrast between foreground and background is not the only consideration; we also need to understand how people actually perceive color and the differences in how they do so.

Our eyes contain three types of cones, specialized photoreceptors, which enable us to differentiate colors. Approximately 1 in 20 people, about 5% of the population, have issues with these cones that limit or change the way they see color. The most common form of color blindness affects men (1 in 12) much more than it affects women (1 in 200).

Color Blindness

Not everyone is color blind in the same way. Some people have issues with the cones in their eyes that distinguish red, while others have difficulties with the cones that distinguish green or blue. But how can a person with normal vision know what a color blind person will see?

Some browsers, notably Google Chrome and Microsoft Edge, both based on Chromium, include a rendering tab that allows evaluating how users with vision impairments will perceive a page. Storybook also has an accessibility addon that includes a vision filter, which has been available since version 8.

I'm going to focus on the browser tools, since not everyone uses Storybook. The easiest way I've found to open the render tab is to open dev tools (F12), then press Escape to open the tab panel. If the render tab isn't showing, click the chevron in the menu to open it.

The Render tab offers many useful options for working on a design. One option is the dropdown "Emulate vision deficiencies". Activating the drop-down shows emulations for blurred vision, reduced contrast, protanopia (no red), deuteranopia (no green), tritanopia (no blue), and achromatopsia (no color). Use these tools to determine how your work looks to those who live with these issues.

When designing with color blindness in mind, the focus is not on the specific color so much as on ensuring the displayed colors have sufficient contrast for differentiation. WCAG 1.4.11 Non-Text Contrast is the applicable success criterion; while WCAG 1.3.3 Sensory Characteristics and WCAG 1.4.1 Use of
Color
also comes into play as adjuncts to the non-text contrast.

Instructions that include sensory characteristics, such as "press the red outlined button," aren't useful for someone who cannot see red, or for someone whose color vision is reduced or damaged, or for someone who sees no color at all. The same is true when common color conventions, such as red text indicating an error, are displayed without any distinct information that the text is an error message.

Specific colors aren't the issue; the main thrust of color accessibility is ensuring that a color change is discernible and that all screen users can see the difference.

As a test, I'm going to set the backgrounds of the buttons and links in the sublists to green and red, differing only in hue. Saturation and lightness are consistent. The HSL color space makes it really easy.

& button {
background-color: hsl(120deg 73.44% 74.9%);
}
& a[href] {
background-color: hsl(10.38deg 73.44% 74.9%);
}
Enter fullscreen mode Exit fullscreen mode

I've provided an animated GIF to demonstrate the color changes using the developer tools to render the different emulations.

silent video demonstrating color changes through developer tools rendering to view color blind emulations

When colors are highly saturated, the contrast is enough to allow their use together, even when they are green and red. But what happens when colors are less saturated?

& button {
  background-color: hsl(120deg 33.44% 74.9%);
}
& a[href] {
  background-color: hsl(10.38deg 33.44% 74.9%);
}
Enter fullscreen mode Exit fullscreen mode

An animated GIF showing the same hues, with less saturation. Notice how color contrast is no longer sufficient for users who suffer from Deuteranopia and Achromatopsia.

Lower saturation reduces the color contrast to a level insufficient for users with Deuteranopia (no green) or Achromatopsia (no color).

Saturation is the main culprit when it comes to color contrast issues. You can use desaturated colors; just make sure to check them so the message you are sending isn't lost.

Styling for Appearance

The last time I demonstrated CSS layouts, I combined some appearance into the CSS. I prefer to separate layout and appearance code into two sets of CSS nested structures, and in doing so, I've moved all appearance type styles, colors, padding and borders into their own cascade.

The full stylesheet may be found in the Tab Handling Release 0.7.0 styledHorizontal.css)

@layer system-component {
  nav.horizontal-navigation {
    /* Layout */
    --min-list-width: var(--sp-px);
    padding: calc(var(--sp-px) * 16) 0;
    position: relative;

    /* Top Row nav > ul > li */

    & > ul {
      align-items: normal;
      justify-items: flex-start;
      column-gap: calc(var(--sp-px) * 24);

      & > li {
        display: flex;
        align-content: center;
        align-items: center;
        position: relative;

        /* sub navigation (not top row) */

        & > ul {
          min-width: var(--min-list-width);
          position: absolute;
          top: calc(var(--sp-px) * 36);
          width: fit-content;
          z-index: 3;

          & li {
            width: 100%;
          }

          & > li {
            & button,
            & a[href] {
              display: flex;
              flex-direction: row;
              flex-wrap: nowrap;
              justify-content: flex-start;
            }
          }

          & ul {
            position: relative;
            row-gap: unset;
            width: 100%;
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The layout style removes all appearance-related values, leaving a clean stylesheet with nothing extraneous cluttering it up. Another selector pattern is replicated for appearance, with any padding references moved into it.

/* Appearance */
nav.horizontal-navigation {
    & > ul {
        & > li {
            & > button,
            & > a[href] {
                padding-left: 0;
            }

            /* sub navigation (not top row) */

            & > ul {
                padding: 0;

                & li {
                }

                & > li {
                    &:first-child {
                        padding-top: calc(var(--sp-px) * 8);
                    }

                    &:last-child {
                        padding-bottom: calc(var(--sp-px) * 8);
                    }

                    & button,
                    & a[href] {
                        padding: calc(var(--sp-px) * 8) calc(var(--sp-px) * 16) 0 calc(var(--sp-px) * 8);
                    }
                }

                & ul {
                    padding: 0 calc(var(--sp-px) * 16) 0 0;

                    & > li {
                        padding-left: calc(var(--sp-px) * 16);
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Appearance styling can now be applied, specifically to the generic buttons and links.

/* Generic */

nav.horizontal-navigation {
white-space: nowrap;

& button,
& a[href] {
  background-color: transparent;
  border-color: transparent;
  border-radius: 0;
  border-style: solid;
  border-width: calc(var(--sp-px) * 1) calc(var(--sp-px) * 3);
  color: var(--component-text-color);
  text-align: left;
  width: 100%;
  }
}
Enter fullscreen mode Exit fullscreen mode

Navigation labels should be short, but I'd rather expand the list width than have them wrap. Notice the border width is applied, even though no border (border-color: transparent) is applied. I've standardized my CSS to apply box-sizing: border-box on all elements, which means my borders sit on top of the background, and any change to the border width affects the content width. To minimize visual shifting, I pre-set the border-width and set the color to transparent until a border is necessary.

Buttons and links are set to 100% to fill the list element they reside in. This helps increase the target size for operability via a pointer, such as a mouse, trackpad or finger, especially for those with shaky hands.

Defining State Changes

Focusable elements need to reflect their states: hover, focus and active. Focus is to the keyboard, as hover is to a pointer. The focus-visible pseudo-class handles the outline, which can indicate the current cursor position to a screen/keyboard user. Focus and hover can be set together to provide the same visual styling regardless of how a screen reader user interacts with the element.

& > ul {
    & > li {
        & > button,
        & > a[href] {
            border-bottom-width: calc(var(--sp-px) * 3);
            border-bottom-color: transparent;
            font-weight: 500;
            min-width: calc(var(--sp-px) * 60);
            padding: calc(var(--sp-px) * 8) 0;
            padding-right: calc(var(--sp-px) * 24);
            text-decoration: none;

            & svg {
                background-color: transparent;
            }

            &:focus-visible {
                outline: calc(var(--sp-px) * 1) solid var(--purple-4);
            }

            &:focus,
            &:hover {
                border-color: transparent;
                border-bottom-color: var(--purple-5);
            }

            &:active {
                background-color: var(--purple-2);
                border-bottom-color: var(--purple-5);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Whether a user hovers over a focusable element or tabs to it, the cursor pointer should change to indicate that the item can be interacted with; this happens automatically when working with focusable HTML elements, but may need to be added when creating more complex widgets. Colors and borders can be added, removed or changed to indicate the state. When using colors to indicate these state changes, make sure the colors have sufficient saturation and contrast to signal the change to the user.

The styling adds a bottom border that seems to float under the text of the top-row buttons and links and changes on focus, hover or when the element is active.

An animated GIF demonstrating the application of a colored border appearing when a top row link or button is hovered over or focused on..

Styling the SubLists

The CSS originally applied to the sublist navigation is modified to remove gaps and refine the borders around the enclosing list.

& > ul > li {
  & > ul {
    background-color: var(--component-background);
    border-color: var(--gray-4);
    border-style: solid;
    border-width: calc(var(--sp-px) * 1);
    border-radius: calc(var(--sp-px) * 10);
    box-shadow: calc(var(--sp-px) * 7) calc(var(--sp-px) * 3) calc(
        var(--sp-px) * 5
      ) oklch(var(--raw-purple-4) / 0.4);
    gap: unset;
    padding: 0;
    & li,
    & li ul {
      gap: unset;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Image demonstrating adding a list box shadow

The dropdown corners have been slightly rounded, and a box shadow has been added to create subtle depth. Unfortunately, color space functions like oklch() and hsl() will not respond to design token changes for either the color or the opacity, so I've hard-coded a color and opacity into the box shadow color.

& > ul > li {
    & > ul {
        & li {
            border-bottom-width: calc(var(--sp-px) * 1);
            border-bottom-style: solid;
            border-bottom-color: var(--purple-5);

            &:last-child {
                border-bottom-color: transparent;
            }
        }

        & li > button[aria-expanded="true"] {
            border-bottom-width: calc(var(--sp-px) * 1);
            border-bottom-style: solid;
            border-bottom-color: var(--purple-5);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

I want the bottom border of a list item in a sublist to change depending on whether the sublist is showing. I can do this by checking whether the button has aria-expanded="true". If the condition can be met by aria or another attribute, use it instead of adding yet another class.

Outline on Focus

  & > li {
    & button,
    & a[href] {
        border-bottom-color: var(--purple-3);
        border-bottom-style: solid;
        border-bottom-width: calc(var(--sp-px) * 1);
        font-weight: 400;
        padding: calc(var(--sp-px) * 8) calc(var(--sp-px) * 16) calc(
                var(--sp-px) * 8
        ) calc(var(--sp-px) * 8);
        text-decoration: none;

        &:focus-visible {
            outline: none;
        }

        &:focus,
        &:hover {
            background-color: var(--purple-1);
            border-left-color: var(--purple-6);
        }

        &[aria-current="page"] {
            border-right-color: var(--purple-5);
        }
    }

    &:has(:focus),
    &:has(:hover) {
        border-left-color: var(--purple-6);
    }
}

& ul {
    padding: 0 calc(var(--sp-px) * 16) 0 0;

    & > li {
        & button,
        & a[href] {
            padding-left: calc(var(--sp-px) * 24);
        }
    }
}

}
Enter fullscreen mode Exit fullscreen mode

I've removed the underlines from links, so buttons and links are harmonized, and I've removed the outline style for focusable elements. I did not do this lightly. Every focusable item should indicate when it receives keyboard focus, and every browser defaults to an outline circle, which, while it can be restyled, should only be removed when something else is set up to replace it, which is for hover and focus.

I'm styling the list item when a button or link receives hover or focus using the :has() pseudo-class, which allows me to style a parent based on an attribute available on a child, in this case, a button or link's hoverable or focusable state.

If a link in a navigation item points to the page a user is currently on, a screen reader may announce it via the aria-current attribute. This conforms to the success criterion 1.3.1 Info and Relationships, which is a catchall for all scenarios relating to screen readers. But doesn't conformity also require a visual indication?

Once again, styling off the aria-current attribute allows future team members, including yourself, to understand why the style is being applied, rather than a class name that may or may not be indicative. Because I'm using static examples, I've tied the Appendices link under references to match the current page.

Nested sublists are given extra left padding to visually indicate that they are related to and follow their controlling button.

Most of the styling is now completed. All that's left is to tweak the top-left and top-right borders with a border radius and shift the padding for a visual indication of child list items.

> ul {
  & > li > ul {
    & > li:first-child {
      &:has([aria-current="page"]) > a[href] {
        border-top-right-radius: calc(var(--sp-px) * 10);
      }
      & > button,
      & > a[href] {
        border-top-left-radius: calc(var(--sp-px) * 10);
      }
    }
  }

  & li {
    & button,
    & a[href] {
      padding-left: calc(var(--sp-px) * 16);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Focusable elements under the top row need their own styling. A faint line separates the elements, and while it disappears in a low-contrast emulation, the sufficient spacing between them compensates. The line stays stable regardless of colored vision deficiencies.

Am I the only one who finds using nested CSS and HTML selectors a lot easier than relying on classes?

An animated GIF depicting the fully styled horizontal menu

Top comments (0)