Exploring HSLA Color Format for React: TypeScript, Styled Components, Color Variants, Colors Generator, Color Picker
Tags: hsla, typescript, styledcomponents, react
Published: 2023-07-09 10:31:06

✨ [**Watch on YouTube**](https://youtu.be/f3_TYR-8Sd8)  |  🐙 [GitHub](https://github.com/RodionChachura/reactkit/blob/main/pages/hsla.tsx)  |  🎮 [Demo](https://reactkit.radzion.com/hsla)

HEX and RGB color formats are not intuitive for us humans. We don't know how to make #ff5343 a bit lighter by changing its hex parameters. Hopefully, there is an HSLA format with four easy-to-understand parameters. More than that, we can comfortably create new color variants through code using an HSLA TypeScript implementation I will share with you today. To take a grasp of the format, you can play with a color picker I prepared for you at [reactkit.radzion.com/hsla](https://reactkit.radzion.com/hsla). We will also see how HSLA fits nicely with styled-components and React. As a bonus, we'll also check how I've made the sliders for this color picker. Let's jump in!

![Demo from ReactKit](https://cdn-images-1.medium.com/proxy/1*7H8WsARids_L1Z1M6ONvQA.jpeg)

## HSLA Color Format

Since we want more control over the color, generate them, and make variants, we better store it in an object rather than a string. `HSLA` is a simple class with a constructor receiving hue, saturation, lightness, and alpha parameters. It has only two methods:

- `toCssValue` - converts the color to a CSS string.
- `getVariant` - creates a new color variant based on the modifiers passed as an argument. Modifier is a function that takes a color parameter and returns a new value for it.

```tsx
import { degreesInCircle } from "lib/shared/utils/degreesToRadians"
import { enforceRange } from "lib/shared/utils/enforceRange"

export const hslaKeys = ["h", "s", "l", "a"] as const

export type HSLAParameter = (typeof hslaKeys)[number]

export type ColorModifiers = Partial<
  Record<HSLAParameter, (parameter: number) => number>
>

export const hslaParamMaxValue: Record<HSLAParameter, number> = {
  h: degreesInCircle,
  s: 100,
  l: 100,
  a: 1,
}

export class HSLA {
  private _h = 0
  get h(): number {
    return this._h
  }
  set h(newH: number) {
    this._h = enforceRange(newH, 0, hslaParamMaxValue.h)
  }

  private _l = 0
  get l(): number {
    return this._l
  }
  set l(newL: number) {
    this._l = enforceRange(newL, 0, hslaParamMaxValue.l)
  }

  private _s = 0
  get s(): number {
    return this._s
  }
  set s(newS: number) {
    this._s = enforceRange(newS, 0, hslaParamMaxValue.s)
  }

  private _a = 0
  get a(): number {
    return this._a
  }
  set a(newA: number) {
    this._a = enforceRange(newA, 0, hslaParamMaxValue.a)
  }

  constructor(h: number, s: number, l: number, a = 1) {
    this.h = h
    this.s = s
    this.l = l
    this.a = a
  }

  toCssValue() {
    return `hsla(${this.h},${this.s}%,${this.l}%,${this.a})`
  }

  getVariant(modifiers: ColorModifiers) {
    const [h, s, l, a] = hslaKeys.map((key) => {
      const value = this[key]
      const modifier = modifiers[key]

      return modifier ? modifier(value) : value
    })

    return new HSLA(h, s, l, a)
  }
}
```

To ensure that color won't break because of incorrect parameters we rely on rarely used getter and setters. Actually it might be the only time I've used it in TypeScript. The `enforceRange` function is a simple utility that ensures that the value is between the min and max values. For every parameter 0 is the minimum value. The maximum value is 360 for hue, 100 for saturation and lightness, and 1 for alpha.

## HSLA & Color Theme

Instead of relying on `string` type to represent a color in our app, we can now use the `HSLA` type. Here's an example of how I define the `ThemeColors` interface:

```tsx
import { HSLA } from "../colors/HSLA"

export type ThemeColors = {
  primary: HSLA

  attention: HSLA

  alert: HSLA
  idle: HSLA
  success: HSLA

  foreground: HSLA
  background: HSLA

  text: HSLA
  textSupporting: HSLA
  textShy: HSLA

  mist: HSLA
  mistExtra: HSLA

  overlay: HSLA

  contrast: HSLA

  white: HSLA
  transparent: HSLA

  getLabelColor: (index: number) => HSLA
}
```

Then we'll usually have dark and light theme implementations of the `ThemeColors` interface:

```tsx
import { DefaultTheme } from "styled-components"
import { HSLA } from "lib/ui/colors/HSLA"
import { sharedColors } from "./shared"
import { generateLabelColorGetter } from "../colors/generateLabelColorGetter"

const backgroundHue = 0
const backgroundSaturation = 0
const backgroundLightness = 10

export const darkTheme: DefaultTheme = {
  name: "dark",
  colors: {
    ...sharedColors,
    foreground: new HSLA(
      backgroundHue,
      backgroundSaturation,
      backgroundLightness + 3
    ),
    background: new HSLA(
      backgroundHue,
      backgroundSaturation,
      backgroundLightness
    ),
    text: new HSLA(0, 0, 100, 0.81),
    textSupporting: new HSLA(0, 0, 61),
    textShy: new HSLA(0, 0, 100, 0.28),

    mist: new HSLA(0, 0, 100, 0.06),
    mistExtra: new HSLA(0, 0, 100, 0.13),

    overlay: new HSLA(backgroundHue, backgroundSaturation, 1, 0.8),

    getLabelColor: generateLabelColorGetter({
      saturation: 56,
      lightness: 52,
    }),

    contrast: new HSLA(0, 0, 100),
  },
  shadows: {
    small:
      "rgb(15 15 15 / 20%) 0px 0px 0px 1px, rgb(15 15 15 / 20%) 0px 2px 4px",
    medium:
      "rgb(15 15 15 / 10%) 0px 0px 0px 1px, rgb(15 15 15 / 20%) 0px 3px 6px, rgb(15 15 15 / 40%) 0px 9px 24px;",
  },
}
```

![Dark Theme Colors](https://cdn-images-1.medium.com/proxy/1*u8ehfcivScnMe85MOTe8pg.jpeg)

## HSLA & Styled Components

We can get the color from the theme and use it in our styled components. Here's an example of assigning background color:

```tsx
background: ${({ theme }) => theme.colors.primary.toCssValue()};
```

Yet it's a bit too much code to write every time. In 90% of cases we just want to use CSS color without making any variation. By using the `getColor` helper we can get color in a concise way:

```tsx
background: ${getColor("primary")};
```

Here's the implementation of the `getColor` helper. It's a closure - a function that returns another function.

```tsx
import { DefaultTheme } from "styled-components"
import { ThemeColors } from "./ThemeColors"

interface ThemeGetterParams {
  theme: DefaultTheme
}

type ColorName = keyof Omit<ThemeColors, "getLabelColor">

export const getColor =
  (color: ColorName) =>
  ({ theme }: ThemeGetterParams) =>
    theme.colors[color].toCssValue()
```

There are also situations where the styled component takes a parameter and than we should match a color to that value. For example, here we want to make color red if the input is invalid:

```tsx
color: ${({ theme, isValid }) => (isValid ? theme.colors.textSupporting : theme.colors.alert).toCssValue()};
```

To make it more concise we can use the `matchColor` helper:

```tsx
  color: ${matchColor("isValid", {
    true: "textSupporting",
    false: "alert",
  })};
```

It has a bit more complex types because we want to support booleans and union types. Here's the implementation:

```tsx
type BooleanMatcher = { true: string; false: string }
type Matcher<T extends string | number | symbol> = { [key in T]: string }
type MatcherType<T> = Extract<T, "string" | "number" | "symbol">

export const matchColor =
  <T extends ThemeGetterParams, K extends keyof T, U = T[K]>(
    variable: K,
    matcher: U extends boolean ? BooleanMatcher : Matcher<MatcherType<U>>
  ) =>
  (params: T) => {
    if (typeof params[variable] === "boolean") {
      const booleanMatcher = matcher as BooleanMatcher
      return params[variable] ? booleanMatcher.true : booleanMatcher.false
    }

    return (matcher as Matcher<MatcherType<U>>)[
      params[variable] as unknown as MatcherType<U>
    ]
  }
```

## Generate HSLA colors

In the `ThemeColors` interface we've seen the `getLabelColor` function. It's a common need for apps to have colored labels for projects, or categories, and so on. We can generate a function that will return a color based on the index. Here's the implementation:

```tsx
import { degreesInCircle } from "lib/shared/utils/degreesToRadians"
import { HSLA } from "./HSLA"

export const labelColorsCount = 12

interface LabelColorGetterParams {
  saturation: number
  lightness: number
}

export const generateLabelColorGetter =
  ({ saturation, lightness }: LabelColorGetterParams) =>
  (labelIndex: number): HSLA => {
    const labelIndexOnInterval =
      (labelIndex % labelColorsCount) / labelColorsCount

    const hue = degreesInCircle * labelIndexOnInterval

    return new HSLA(hue, saturation, lightness)
  }
```

Here we decide to have 12 color options in our labels palette that are evenly distributed on the color wheel. Instead of saving a color in the database or in the app state we can just save the index and then get the color from the function. If later we change the number of colors in the pallette, the function will still work because we are using the mod operator.

![Color picker](https://cdn-images-1.medium.com/proxy/1*586RkyjMgKH0quf8DB-Hyg.jpeg)

To make the colors look good in both dark and light themes we pass diferent saturation and lightness values. Here's how it looks in the dark theme:

```tsx
getLabelColor: generateLabelColorGetter({
  saturation: 56,
  lightness: 52,
}),
```

And here's how it looks in the light theme:

```tsx
getLabelColor: generateLabelColorGetter({
  saturation: 64,
  lightness: 64,
}),
```

## HSLA variants

Sometimes we want to make a color a bit darker or lighter. We can do it by using the `getVariant` method on HSLA class. Here's an example where we make button background a bit darker on hover:

```tsx
const getHoverVariant = (color: HSLA) => color.getVariant({ l: (l) => l * 0.92 })

// ...

background: ${({ theme }) => getHoverVariant(theme.colors.primary).toCssValue()};
```

Another example is the `Tag` component we can use to show project labels or categories. Here we adjust saturation for text color and background to make it look good in both dark and light themes.

```tsx
import styled, { css } from "styled-components"

import { HSLA } from "./colors/HSLA"
import { Text } from "./Text"
import { match } from "lib/shared/utils/match"

export const Tag = styled(Text)<{ $color: HSLA }>`
  border-radius: 8px;
  padding: 4px 8px;
  font-weight: 600;
  font-size: 14px;
  ${({ theme, $color }) =>
    match(theme.name, {
      dark: () => css`
        color: ${$color.getVariant({ s: () => 56, l: () => 60 }).toCssValue()};
        background: ${$color.getVariant({ a: () => 0.06 }).toCssValue()};
      `,
      light: () => css`
        color: ${$color.getVariant({ s: () => 40, l: () => 40 }).toCssValue()};
        background: ${$color.getVariant({ a: () => 0.1 }).toCssValue()};
      `,
    })}
`
```

## How To Make HSLA Color Picker

I've promised to show an implementation of the color picker. We store the HSLA value in the `useState` hook:

```tsx
const { colors } = useTheme()
const [value, setValue] = useState(colors.primary)
```

Then we go over each key and render a CSS Grid container with the name of the parameter, input, and the formatted value:

```tsx
{
  hslaKeys.map((key) => (
    <InputContainer key={key}>
      <Text>{colorParameterName[key]}</Text>
      <ColorParameterInput
        onChange={(parameter) => {
          setValue(value.getVariant({ [key]: () => parameter.toFixed(2) }))
        }}
        value={value[key]}
        getColor={(param) =>
          value.getVariant({ [key]: () => param }).toCssValue()
        }
        max={hslaParamMaxValue[key]}
        step={colorParameterStep[key]}
      />
      <Text weight="bold">{formatColorParameter[key](value[key])}</Text>
    </InputContainer>
  ))
}
```

The `ColorParameterInput` component is just a fancy slider:

```tsx
import { range } from "lib/shared/utils/range"
import { toPercents } from "lib/shared/utils/toPercents"
import { PressTracker } from "lib/ui/PressTracker"
import { defaultTransition } from "lib/ui/animations/transitions"
import {
  InvisibleHTMLSlider,
  InvisibleHTMLSliderProps,
} from "lib/ui/inputs/Slider/InvisibleHtmlSlider"
import { getColor } from "lib/ui/theme/getters"
import { centerContentCSS } from "lib/ui/utils/centerContentCSS"
import { getCSSUnit } from "lib/ui/utils/getCSSUnit"
import { getSameDimensionsCSS } from "lib/ui/utils/getSameDimensionsCSS"
import { interactiveCSS } from "lib/ui/utils/interactiveCSS"
import styled from "styled-components"

export interface ColorParameterInputProps
  extends Omit<InvisibleHTMLSliderProps, "min"> {
  getColor: (param: number) => string
}

const railHeight = 20
const controlBorderWidth = 2
const controlSize = railHeight + controlBorderWidth * 2

const Control = styled.div<{ value: number }>`
  position: absolute;
  left: ${({ value }) =>
    `calc(${toPercents(value)} - ${getCSSUnit(controlSize / 2)})`};
  ${getSameDimensionsCSS(controlSize)};
  transition: outline ${defaultTransition};
  outline: 6px solid transparent;
  border: ${getCSSUnit(controlBorderWidth)} solid ${getColor("contrast")};
  border-radius: 4px;
`

const Container = styled.label`
  width: 100%;
  height: 40px;
  ${interactiveCSS};
  ${centerContentCSS};
  position: relative;

  :focus-within ${Control} {
    outline: 12px solid ${getColor("mistExtra")};
  }

  :hover ${Control} {
    outline-color: ${getColor("mist")};
  }
`

const Line = styled.div`
  width: 100%;
  height: ${getCSSUnit(railHeight)};

  border-radius: 4px;
`

export const ColorParameterInput = ({
  value,
  onChange,
  max,
  step,
  getColor,
}: ColorParameterInputProps) => {
  const colors = range(Math.round(max / step)).map((index) =>
    getColor(index * step)
  )
  return (
    <PressTracker
      onChange={({ position }) => {
        if (position) {
          onChange(Math.round((position.x * max) / step) * step)
        }
      }}
      render={({ props }) => (
        <Container {...props}>
          <InvisibleHTMLSlider
            step={step}
            value={value}
            onChange={onChange}
            min={0}
            max={max}
          />
          <Line
            style={{
              background: `linear-gradient(to right, ${colors.join(", ")})`,
            }}
          />
          <Control value={value / max} />
        </Container>
      )}
    />
  )
}
```

To handle user mouse or touch we rely on an abstract component called `PressTracker` that you can learn more about [here](https://radzion.com/blog/press-tracker). The `InvisibleHTMLSlider` is a hidden range input that will still do its job allowing us to change the value by using the keyboard. To make this nice gradient we rely on the `getColor` property that returns a color for a given parameter value.