Site icon Nona Blog

Managing colour opacity in React Native

This is part 1 in a series of 3 articles on color and alpha in React Native.

Handling opacity on-the-fly in an app isn’t necessarily a trivial thing to do. Most of the time, you want to have your colours defined somewhere (we usually do it in a colors.styles.ts file in our styles folder for each project at Nona) – and then exported for use in the app.

However – even though one’s colour palette may be defined (and defining colour values within our design system is important for projects), setting the alpha values (or opacity) can be quite an ad hoc, when-the-situation-requires-it type of thing.

As a result, I’ve often found the need to set the opacity (50%, for example) for a defined colour (colors.black). The colours in our app are usually hex values (#000000). There’s a little known addition to the hex colour notation, where one can append (or prepend in some cases) a 2-digit alpha value.

Given that we’d usually define our colour something like:

text : {
	color: colors.black,
}

Presuming we needed the color to be 50% black, we could (knowing that 50% in hexadecimal is 80), simply append it to our color with some template literals:

text : {
	color: `${colors.black}80`, // #00000080
}

But, that’s annoying, right? Also calculating an arbitrary % opacity on the fly is a non-trivial thing.

Essentially what we need to do is:

  1. Convert the % to a value between 0 and 1.
  2. Convert that value to a value out of 255 (as each double digit in hex-notation has a possible range of 256 values, being from 0 to 255).
  3. Finally convert this value into a hexadecimal number.
  4. Ensure that the hex value is 2 digits long (pad with 0s if it’s < 2).
  5. and then append it to our given colour.

Thankfully, this is all relatively easy to do with a little javascript function, which we’ll go about building now.

Ok, let’s start (Step #1) with a function that accepts a color and an alpha value – we’ll call it applyAlpha:

const applyAlpha = (color: string, alpha: number) => {}

Next (#2), we’re going to stick with the RGBA convention and accept a value between 0 and 1, and so side-step the need to convert a % value altogether – so the next thing we need to do is convert it to a value out of 256, and make sure we don’t fall foul of javascript’s floating point issues.

Let’s force it to an integer with the toFixed() function:

const applyAlpha = (color: string, alpha: number) => {
  const alpha256 = (alpha * 255).toFixed()
}

Step 3 is to convert this value to a hexadecimal value. Javascript’s Number prototype’s toString function is super handy here.

You can pass it a radix (as in your number’s base) and it’ll handle the conversion for us:

const applyAlpha = (color: string, alpha: number) => {
  const alpha256 = (alpha * 255).toFixed()
  const alphaBase16 = Number(alpha256).toString(16) // we're ensuring this is a number then converting
}

Step 4. We need to pad the value if it’s only 1 digit long:

const applyAlpha = (color: string, alpha: number) => {
  const alpha256 = (alpha * 255).toFixed()
  const alphaBase16 = Number(alpha256).toString(16) // we're ensuring this is a number then converting
	const paddedAlpha = alphaBase16.length() === 1 ? alphaBase16.padStart(1, 0) : alphaBase16
}

Finally (#5), we want to join this value to our colour string and return that from our function:

const applyAlpha = (color: string, alpha: number) => {
  const alpha256 = (alpha * 255).toFixed()
  const alphaBase16 = Number(alpha256).toString(16) // we're ensuring this is a number then converting
	const paddedAlpha = alphaBase16.length() === 1 ? alphaBase16.padStart(1, 0) : alphaBase16  
	return color.concat('', paddedAlpha)
}

And there we have it, alpha values on the fly for our colors!

Usage:

text : {
	color: applyAlpha(colors.black, 0.5), // #00000080
}

Bonus Round 1: Clamp the alpha value

Making sure that the alpha value is in the correct range

We want to ensure that our alpha value is within 0 and 1.

There’s no good way to do this with Typescript, so we’ll need to rely on JS to clamp our value:

const applyAlpha = (color: string, alpha: number) => {
  const alphaClampMin = alpha < 0 ? 0 : alpha
  const alphaClamp = alphaClampMin > 1 ? 1 : alphaClampMin
  const alpha256 = (aplhaClamped * 255).toFixed()
  const alphaBase16 = Number(alpha256).toString(16) // we're ensuring this is a number then converting
  const paddedAlpha = alphaBase16.length() === 1 ? alphaBase16.padStart(1, 0) : alphaBase16  
	return color.concat('', paddedAlpha)
}

Or we can make a quick little util to handle that:

const clamp = (min, max, value) => {
	const valueClampedMin = value < min ? min : value
  return valueClampedMin > max ? max : valueClampedMin
}

const applyAlpha = (color: string, alpha: number) => {
  const alphaClamped = clamp(0,1, alpha)
  const alpha256 = (alphaClamped * 255).toFixed()
  const alphaBase16 = Number(alpha256).toString(16) // we're ensuring this is a number then converting
  const paddedAlpha = alphaBase16.length() === 1 ? alphaBase16.padStart(1, 0) : alphaBase16  
	return color.concat('', paddedAlpha)
}

Bonus Round 2: A functional approach

I personally prefer the functional approach, and make a fair amount of use of the Ramda library whenever I can, so as a bit of a bonus round, let’s refactor our util, making use of some of Ramda’s helpers and a few of our own.

Let’s start with taking a look at our final function:

const applyAlpha = (color: string, alpha: number) => {
  const alpha256 = (alpha * 255).toFixed()
  const alphaBase16 = Number(alpha256).toString(16) // we're ensuring this is a number then converting
  const paddedAlpha = alphaBase16.length() === 1 ? alphaBase16.padStart(2, 0) : alphaBase16  
	return color.concat('', paddedAlpha)
}

We start with converting each of the lines in our function into functional versions, and then we can wrap it all in a pipe, and finally curry the function to give us that bit of extra flexibility:

const alpha256 = (alpha * 255).toFixed()

To get this into a functional flow, we first need to create a fn util version of toFixed:

const toFixed = (val: number) => val.toFixed()

Easy.

Now let’s wire that up into our line above, and we’ll deal with the multiplication with a Ramda util:

import { multiply } from 'ramda

const toFixed = (val: number) => val.toFixed()

// to Fn: 
const applyAlpha = (color: string, alpha: number) => {
  const alpha256 = multiply(255)(alpha) // multiply by 256
	const alpha256ToFixed = toFixed(alpha256) // and ensure it's an integer with toFixed.
}

Next, let’s build on this and convert to a base16.

We’ll need to make a quick util for that:

const toBase = (base: number, val: number) => Number(val).toString(base)

And let’s implement it:

import { multiply } from 'ramda

const toFixed = (val: number) => val.toFixed()
const toBase = (base: number) => (val: number) => Number(val).toString(base)

// to Fn: 
const applyAlpha = (color: string, alpha: number) => {
  const alpha256 = multiply(255)(alpha) // multiply by 256
	const alpha256ToFixed = toFixed(alpha256) // and ensure it's an integer with toFixed.
	const alphaBase16 = toBase(16)(alpha256ToFixed)
}

For the 0 padding, we’ll need another util to handle that:

const padStart = (targetLength: number, padString: string) => (val: string) => val.padStart(targetLength, padString)

And implemented:

import { multiply } from 'ramda

const toFixed = (val: number) => val.toFixed()
const toBase = (base: number) => (val: number) => Number(val).toString(base)
const padStart = (targetLength: number, padString: string) => (val: string) => val.padStart(targetLength, padString)

// to Fn: 
const applyAlpha = (color: string, alpha: number) => {
  const alpha256 = multiply(255)(alpha) // multiply by 256
	const alpha256ToFixed = toFixed(alpha256) // and ensure it's an integer with toFixed.
	const alphaBase16 = toBase(16)(alpha256ToFixed)
	const paddedAlpha = padStart(2, '0')(alphaBase16)
}

And the final conversion, let’s concat this with our string:

import { concat, multiply } from 'ramda

const toFixed = (val: number) => val.toFixed()
const toBase = (base: number) => (val: number) => Number(val).toString(base)
const padStart = (targetLength: number, padString: string) => (val: string) => val.padStart(targetLength, padString)

// to Fn: 
const applyAlpha = (color: string, alpha: number) => {
  const alpha256 = multiply(255)(alpha) // multiply by 256
	const alpha256ToFixed = toFixed(alpha256) // and ensure it's an integer with toFixed.
	const alphaBase16 = toBase(16)(alpha256ToFixed)
	const paddedAlpha = padStart(2, '0')(alphaBase16)
	return concat(color)(paddedAlpha)
}

Fantastic!

As you’ll see each function passes the previous value down into it until we finally return our adjusted color. alpha256 is passed to alpha256ToFixed which is passed to alphaBase16 which is concatenated to our color and returned.

This is perfect for Ramda’s pipe function, which takes a list of functions as its first argument, with our value second and `pipes` it through consecutive functions with a single arity (accepts 1 argument) until it returns the final value at the end. I’m also using ES6’s auto-return by removing our enclosing brackets:

Let’s do this:

import { concat, multiply, pipe } from 'ramda

const toFixed = (val: number) => val.toFixed()
const toBase = (base: number) => (val: number) => Number(val).toString(base)
const padStart = (targetLength: number, padString: string) => (val: string) => val.padStart(targetLength, padString)

const applyAlpha = (color: string, alpha: number) => pipe(
	multiply(255),
	toFixed,
	toBase(16),
	padStart(2, '0'),
	concat(color),
)(alpha)

How sexy does that look!? And super easy to read!

For one final encore, let’s add-in that clamp step – again super easy with Ramda:

import { clamp, concat, multiply, pipe } from 'ramda

const toFixed = (val: number) => val.toFixed()
const toBase = (base: number) => (val: number) => Number(val).toString(base)
const padStart = (targetLength: number, padString: string) => (val: string) => val.padStart(targetLength, padString)

const applyAlpha = (color: string, alpha: number) => pipe(
	clamp(0, 1),
	multiply(255),
	toFixed,
	toBase(16),
	padStart(2, '0'),
	concat(color),
)(alpha)

Usage is exactly the same as before:

text : {
	color: applyAlpha(colors.black, 0.5), // #00000080
}

As promised, let’s add in a bit of currying for flexibility:

import { clamp, concat, curry, multiply, pipe } from 'ramda

const toFixed = (val: number) => val.toFixed()
const toBase = (base: number) => (val: number) => Number(val).toString(base)
const padStart = (targetLength: number, padString: string) => (val: string) => val.padStart(targetLength, padString)

const applyAlpha = curry((color: string, alpha: number) => pipe(
	clamp(0, 1),
	multiply(255),
	toFixed,
	toBase(16),
	padStart(2, '0'),
	concat(color),
)(alpha))

This gives us a bit of magic.

We can now front-load our colour into a variable, that then simply waits for us to provide an alpha value whenever we want:

const greenWithAlpha = applyAlpha(colors.green)

const style = {
	text : {
		color: greenWithAlpha(0.9),
		background: greenWithAlpha(0.1),
	}
}

BONUS ROUND #3 – ignore alpha if alpha is 1:

A small win, but we obviously don’t need to add alpha if it’s 1, because #000000 and #000000FF is exactly the same, the one’s just a bit more confusing if you ever have to look at it. So, we want to ignore the concat in this case. We’ll use another Ramda util to help us out here.

ifElse is simply a wrapper for if / else and always will always return whatever is passed to it (and ignore the current value in the pipe):

import { always, clamp, concat, curry, ifElse, multiply, pipe } from 'ramda

const toFixed = (val: number) => val.toFixed()
const toBase = (base: number) => (val: number) => Number(val).toString(base)
const padStart = (targetLength: number, padString: string) => (val: string) => val.padStart(targetLength, padString)

const applyAlpha = curry((color: string, alpha: number) => pipe(
	clamp(0, 1),
	ifElse(
		eq(1),
		always(color), 
		pipe(
			multiply(255),
			toFixed,
			toBase(16),
			padStart(2, '0'),
			concat(color),
		),
	),
)(alpha))

That’s all, folks!

And that’s about that for today.

As one final bit of love, here are some jest tests to help for when you want to TDD this as you code along:

describe('applyAlpha', () => {
  it.each([
    ['#ffffff', 0.5, '#FFFFFF80'],
    ['#fefefe', 0.5, '#FEFEFE80'],
  ])(
    'should return a hex color with alpha value (8 digit) from a 6-digit hex and decimal alpha value',
    (color, alpha, expected) => {
      const result = SUT.applyAlpha(color)(alpha)

      expect(result).toBe(expected)
    },
  )

  it.each([
    ['#ffffff', 0.5, '#FFFFFF80'],
    ['#ffffff', 0.8, '#FFFFFFCC'],
    ['#ffffff', 0.01, '#FFFFFF03'],
  ])('should accept alpha values in the range 0 - 1', (color, alpha, expected) => {
    const result = SUT.applyAlpha(color)(alpha)

    expect(result).toBe(expected)
  })

  it.each([
    ['#ffffff', 1, '#FFFFFF'],
    ['#eeeeee', 1, '#EEEEEE'],
  ])('should not add alpha value if alpha value is 1', (color, alpha, expected) => {
    const result = SUT.applyAlpha(color)(alpha)

    expect(result).toBe(expected)
  })

  it.each([
    ['#ffffff', -1, '#FFFFFF00'],
    ['#ffffff', 2, '#FFFFFF'],
  ])('should clamp alpha values outside a range of 0 <> 1', (color, alpha, expected) => {
    const result = SUT.applyAlpha(color)(alpha)

    expect(result).toBe(expected)
  })

  it('should return the correct value when both values are provided simultaneously', () => {
    const result = SUT.applyAlpha('#ffffff', 0.5)

    expect(result).toBe('#FFFFFF80')
  })
})

 If you’d like to partner with a team that genuinely cares about building better tech products, get in touch with us today. Click here to book a consultation with one of our principals.

Exit mobile version