Building an Interactive Sparkline Graph with D3

D3 is a great JavaScript library for building data visualizations using SVG elements. Today we’re going to walk through how to use it to build a simple line graph with an interactive element, taking inspiration from the NPM website.

What we’re building

Visit any individual package page on the NPM website and you’ll notice a small line graph on the right, showing the total weekly downloads trend over a period, with the total figure to the left.

Screenshot of the D3 NPM page with sparkline graph shown on the right

This type of chart is known as a sparkline. If you hover over the graph on the NPM site, you can scrub through the trend and see the weekly downloads figure for the preceding week, marked by a vertical line and circle. We’re going to build something similar, with a few of our own adaptations. If you’d rather jump straight into the code yourself, you can find the complete demo here.

It’ll come in handy if you have some familiarity with SVG elements. SVGs use their own internal co-ordinate system. For a primer I recommend this timeless article by Sara Soueidan on SVG Coordinate Systems and Transformations.

Data

The first thing we’ll need is some data to work with. I’ve created an API endpoint we can use to fetch some data here. We could use the Fetch API to retrieve it, but instead we’re going to use D3, which conveniently parses the data for us.

HTML

Now let’s add some HTML. We should make sure that our markup makes sense without JS in the first instance. (Let’s assume that our weekly downloads total is a known value, perhaps coming from a CMS.)

We’re adding some data attributes that we’ll need to reference in our JS.

<div class="chart-wrapper" data-wrapper>
	<div>
		<h3 data-heading>Weekly downloads</h3>
		<p data-total>800</p>
	</div>
	<figure data-chart></figure>
</div>

If we choose to, we could append a static image to the figure element, to display while our data is loading.

CSS

We’re going to use D3 to draw an SVG chart, so we’ll include some base CSS styles to set a maximum width on the SVG and center the component within the viewport:

* {
	box-sizing: border-box;
}

body {
	min-height: 100vh;
	display: grid;
	place-items: center;
}

figure {
	margin: 0;
}

svg {
	width: 100%;
	height: auto;
}

.chart-wrapper {
	max-width: 600px;
}

D3

To use the D3 library we’ll first need to add it to our project. If you’re using a bundler you can install the NPM package and import it as follows:

import * as d3 from 'd3'

Otherwise, you can download it direct from the D3 website. D3 is quite a large library, and we’re only going to use parts of it for our line graph. Later on we’ll look at how to reduce the size of our bundle by only importing the modules we need.

Now we can fetch the data using D3’s json method:

d3.json('https://api.npoint.io/6142010a473d754de4e6')
	.then(data => {
		console.log(data)
	})
	.catch(error => console.log(error))

We should see the data array logged to the console in our developer tools.

Preparing the data

First let’s create a function for drawing our chart, which we’ll call once we’ve successfully fetched the data:

const draw = (data) => {
	console.log(data)
}

d3.json('https://api.npoint.io/6142010a473d754de4e6')
	.then(data => {
		draw(sortedData)
	})
	.catch(error => console.log(error))

We should still see our data logged to the console. But before we can draw our chart, we’ll need to sort the data array by date. Currently our data array looks something like this, with the dates as strings:

[
	{ 
		date: "2021-12-23T04:32:20Z",
		downloads: 445
	},
	{ 
		date: "2021-07-20T13:41:01Z",
		downloads: 210
	}
	// etc.
]

We’ll need to convert the date strings into JavaScript date objects. Let’s write a function that first of all converts the string to a date object, then sorts the values by date in ascending order, using D3’s ascending method:

const sortData = (data) => {
	/* Convert to date object */
	return data.map((d) => {
		return {
			...d,
			date: new Date(d.date)
		}
	})
	/* Sort in ascending order */
	.sort((a, b) => d3.ascending(a.date, b.date))
}

We’ll pass the sorted data into our draw function:

fetch('https://api.npoint.io/897b3f7b5f6a24dcd0cf')
	.then(response => response.json())
	.then(data => {
		const sortedData = sortData(data)
		draw(sortedData)
	})
	.catch(error => console.log(error))

Drawing the chart

Now we’re ready to start creating our data visualization. Let’s first of all define the dimensions of our chart, which we’ll use to draw the SVG at the required size:

const dimensions = {
	width: 600,
	height: 200
}

In our draw function, we’re going to use D3’s select method to select the wrapper element containing our figure, heading and downloads count:

/* In `draw()` function */
const wrapper = d3.select('[data-wrapper]')

D3 selections are more powerful than using querySelector, as they allow us to bind data to DOM elements, as well as easily append elements and add or modify attributes. We can then select the figure element and append a new SVG, using our pre-defined dimensions to set the viewbox:

/* In `draw()` function */
const svg = wrapper
	/* Select the `figure` */
	.select('[data-chart]')
	/* Append the SVG */
	.append('svg')
	.attr('viewBox', `0 0 ${dimensions.width} ${dimensions.height}`)

If we inspect our page, we should now see an SVG element is present inside the figure, but it’s not yet visible as we haven’t given it any color. It might be a good idea to add an outline in our CSS, so that we can easily see that the SVG has been created!

svg {
	width: 100%;
	height: auto;
	outline: 1px solid;
}

You might notice a jump in the layout once the SVG is created. We can fix that by adding an aspect ratio to the figure element. That way it’ll be rendered at the correct height straight away (in browsers that support the aspect-ratio property).

figure {
	margin: 0;
	aspect-ratio: 6 / 2;
}

Drawing the line

So far so good, but here’s where things get a little more complex. Don‘t worry, we’ll walk through it step-by-step!

We’re going to draw the trend line on our chart by appending a path element. But before we can do that, we need to create the scales that will enable us to plot the data within the SVG co-ordinate system. (For more on this, read the tutorial Introduction to D3’s scales by Observable.)

Accessor functions

In Amelia Wattenberger’s book, Fullstack D3 and Data Vizualisation, she recommends creating accessor functions to return the x and y values for any given data point. We’re going to need to refer to those values quite a bit, so let’s do that now.

const xAccessor = (d) => d.date
const yAccessor = (d) => d.downloads

It may seem unnecessary given their simplicity, but if we ever need to make any changes (say, a dataset with a different set of keys) we’ll be grateful to have just one place to update those values!

Scales

Our chart’s x-axis will be time-based — using the date values from our data, while the y-axis will use a linear scale to plot the number of downloads. We’ll need D3’s scaleTime and scaleLinear methods respectively.

When creating our scales we need to set both the domain and the range properties. The domain contains the smallest and largest data values that need to be plotted. The range contains the dimensions onto which we’ll plot the data. D3 does the work behind the scenes to scale the domain to the range and plot the position of each data point accordingly. The concept is illustrated in this demo. Hover over the range area and you’ll see the pointer’s position scaled within the domain area.

See the Pen
D3 domain/range
by Michelle Barker (@michellebarker)
on CodePen.0

As our data is already sorted in the correct order, the domain value for the x-axis will be an array containing the date values of our first and last data items:

/* In `draw()` function */
const xDomain = [data[0].date, data[data.length - 1].date]

This is where our accessor functions come in. We could instead use the xAccessor() function to get the desired values for the x-axis:

/* In `draw()` function */
const xDomain = [xAccessor(data[0]), xAccessor(data[data.length - 1])]

However, there is a simpler way, using D3’s extent method. We pass in our data array and the accessor function, and it returns the highest and lowest values as an array. It works even if the data is unsorted.

/* In `draw()` function */
const xDomain = d3.extent(data, xAccessor)

The range is simpler still: As our line will need to go all the way across our SVG, from left to right, our range will go from 0 to the SVG viewbox width.

/* In `draw()` function */
const xDomain = d3.extent(data, xAccessor)

const xScale = d3.scaleTime()
	.domain(xDomain)
	.range([0, dimensions.width])

Our y-axis will be similar, but with a small difference: If we use only the smallest and largest values for the domain, our trend line may appear to fluctuate wildly with even a small difference in the number of downloads. For example, if the number of downloads stayed fairly steady at between 1000 and 1100 per day, our chart would nonetheless display a line that zig-zags right from the bottom to the top of the chart, because a narrow domain is mapped to a (comparatively) wide range. It would be better if we mapped our domain with the lowest value as zero (as it’s impossible to have a negative number of downloads!).

So for the y-axis we’ll set the domain in a slightly different way, using D3’s max function to return only the highest value. We’ll also use the height instead of width from our dimensions object for the range, and D3’s scaleLinear method (which creates a continuous scale) rather than scaleTime.

You might notice that we’ve flipped the range values in this case. That’s because the SVG co-ordinate system begins with 0 at the top, and higher values move an SVG element downwards. We need the low values in our domain to be displayed further down the SVG view box than high values — which in fact means mapping them to higher viewbox co-ordinates!

/* In `draw()` function */
const yDomain = [0, d3.max(data, yAccessor)]

const yScale = d3.scaleLinear()
	.domain(yDomain)
	.range([dimensions.height, 0])
Illustration showing the chart’s axis increasing from bottom to top, whereas the viewbox y co-ordinates increase from top to bottom

Line generator

Once we have our scales set up, we can use D3’s line() function to plot the path scaled to fit our SVG viewbox. We’ll create a line generator:

const lineGenerator = d3.line()
	.x((d) => xScale(xAccessor(d)))
	.y((d) => yScale(yAccessor(d)))

Then we’ll append a path element to our SVG, and use the line generator for the d attribute (the attribute that actually defines the shape of the path). We’ll use the datum() method to bind the data to the path element. (Read more about data binding in this article.)

/* In `draw()` function */
const line = svg
	/* Append `path` */
	.append('path')
	/* Bind the data */
	.datum(data)
	/* Pass the generated line to the `d` attribute */
	.attr('d', lineGenerator)
	/* Set some styles */
	.attr('stroke', 'darkviolet')
	.attr('stroke-width', 2)
	.attr('stroke-linejoin', 'round')
	.attr('fill', 'none')

We’re also setting some styles for the fill and stroke of the path. You should now see the plotted path.

Creating the filled area

Now that we have our line, our next step is to create the filled area below the path. We could try setting a fill color on our line:

/* In `draw()` function */
line.attr('fill', 'lavender')

Unfortunately that won’t produce the desired effect!

Purple line with light purple fill

Luckily, D3 has an area() function that works similarly to line(), and is designed exactly for this use case. Instead of a single y parameter, it requires two y values: y0 and y1. This is because it needs to know where to start and end the filled area. In our case, the second y value (y1) will be the height value from our dimensions object, as the area needs to be filled from the bottom of the chart.

/* In `draw()` function */
const areaGenerator = d3.area()
	.x((d) => xScale(xAccessor(d)))
	.y1((d) => yScale(yAccessor(d)))
	.y0(dimensions.height)

Just like the line before, we’ll append a path element to the SVG and pass in the area generator for the d attribute.

/* In `draw()` function */
const area = svg
	.append('path')
	.datum(data)
	.attr('d', areaGenerator)
	.attr('fill', 'lavender')

At this point our filled area is partially obscuring the stroke of the primary line (you might notice the stroke appears thinner). We can fix this by changing the order so that we draw the filled area before the line within the draw() function. (We could also fix it with z-index in our CSS, but I prefer this way as it doesn’t require any additional code!)

Curved lines

Our line currently looks quite jagged, which is not especially pleasing to the eye. D3 provides us with a number of curve functions to choose from. Let’s add a curve to our line and area generators:

/* In `draw()` function */

/* Area */
const areaGenerator = d3.area()
	.x((d) => xScale(xAccessor(d)))
	.y1((d) => yScale(yAccessor(d)))
	.y0(dimensions.height)
	.curve(d3.curveBumpX)

/* Line */
const lineGenerator = d3.line()
	.x((d) => xScale(xAccessor(d)))
	.y((d) => yScale(yAccessor(d)))
	.curve(d3.curveBumpX)

Interaction

The next step is to add an interactive marker, which will move as the user hovers over the chart. We’ll need to add a vertical line, which will move horizontally, and a circle, which will move both horizontally and vertically.

Let’s append those SVG elements. We’ll give them each an opacity of 0, and position them on the far left. We only want them to appear when the user interacts with the chart.

/* In `draw()` function */
const markerLine = svg
	.append('line')
	.attr('x1', 0)
	.attr('x2', 0)
	.attr('y1', 0)
	.attr('y2', dimensions.height)
	.attr('stroke-width', 3)
	.attr('stroke', 'darkviolet')
	.attr('opacity', 0)
	
const markerDot = svg
	.append('circle')
	.attr('cx', 0)
	.attr('cy', 0)
	.attr('r', 5)
	.attr('fill', 'darkviolet')
	.attr('opacity', 0)

Now let’s use D3’s on method to move our markers when the user hovers. We can use the pointer method which, unlike clientX/clientY, will return the SVG co-ordinates of the pointer’s position (when the event target is an SVG), rather than the viewport co-ordinates. We can update the position of the markers with those co-ordinates, and switch the opacity to 1.

/* In `draw()` function */
svg.on('mousemove', (e) => {
	const pointerCoords = d3.pointer(e)
	const [posX, posY] = pointerCoords
	
	markerLine
		.attr('x1', posX)
		.attr('x2', posX)
		.attr('opacity', 1)
	
	markerDot
		.attr('cx', posX)
		.attr('cy', posY)
		.attr('opacity', 1)
})

Now we should see the line and circle moving with our cursor when we hover on the chart. But something’s clearly wrong: The circle is positioned wherever our cursor happens to be positioned, whereas it should follow the path of the trend line. What we need to do is get the x and y position of the closest data point as the user hovers, and use that to position the marker. That way we also avoid the marker being positioned in between dates on the x-axis.

Bisecting

To get the nearest value to the user’s cursor position, we can use D3’s bisector method, which finds the position of a given value in an array.

First we need to find the corresponding value for the position of the cursor. Remember the scales we created earlier? We used these to map the position of the data values within the SVG viewbox. But we can also invert them to find the data values from the position. Using the invert method, we can find the date from the pointer position:

/* In `draw()` function */
svg.on('mousemove', (e) => {
	const pointerCoords = d3.pointer(e)
	const [posX, posY] = pointerCoords
	
	/* Find date from position */
	const date = xScale.invert(posX)
})

Now that we know the exact date at any point when we’re hovering, we can use a bisector to find the nearest data point. Let’s define our custom bisector above:

/* In `draw()` function */
const bisect = d3.bisector(xAccessor)

Remember, this is equivalent to:

const bisect = d3.bisector(d => d.date)

We can use our bisector to find the closest index to the left or right of our position in the data array, or it can return whichever is closest. Let’s go for that third option.

/* In `draw()` function */
const bisect = d3.bisector(xAccessor)

svg.on('mousemove', (e) => {
	const pointerCoords = d3.pointer(e)
	const [posX, posY] = pointerCoords
	
	/* Find date from position */
	const date = xScale.invert(posX)
	
	/* Find the closest data point */
	const index = bisect.center(data, date)
	const d = data[index]
}

If we console log d at this point we should see the corresponding data object.

To get the marker position, all that remains is to use our scale functions once again, mapping the data value to the SVG co-ordinates. We can then update our marker positions with those values:

/* In the `mousemove` callback */
const x = xScale(xAccessor(d))
const y = yScale(yAccessor(d))

markerLine
	.attr('x1', x)
	.attr('x2', x)
	.attr('opacity', 1)

markerDot
	.attr('cx', x)
	.attr('cy', y)
	.attr('opacity', 1)

(Read more on D3 bisectors here.)

Updating the text

We also want to update the text showing the date range and the number of weekly downloads as the pointer moves. In our data we only have the current date, so at the top of the file let’s write a function that will find the date one week previously, and format the output. We’ll use D3’s timeFormat method for the formatting.

To find the date one week previously, we can use D3’s timeDay helper. This returns a date a given number of days before or after the specified date:

const formatDate = d3.timeFormat('%Y-%m-%d')

const getText = (data, d) => {
	/* Current date */
	const to = xAccessor(d)
	
	/* Date one week previously */
	const from = d3.timeDay.offset(to, -7)
	
	return `${formatDate(from)} to ${formatDate(to)}`
}

Then we’ll call this function to update the text on mouse move:

/* In the `mousemove` callback */
d3.select('[data-heading]').text(getText(data, d))

Updating the total downloads text is a simple one-liner: We select the element, and update the inner text with the corresponding value using our accessor function:

d3.select('[data-total]').text(yAccessor(d))

Resetting

Finally, when the user’s pointer leaves the chart area we should hide the marker and set the text to display the last known value. We’ll add a mouseleave callback:

/* In `draw()` function */
svg.on('mouseleave', () => {
	const lastDatum = data[data.length - 1]
	
	/* Hide the markers */
	markerLine.attr('opacity', 0)
	markerDot.attr('opacity', 0)
	
	/* Reset the text to show latest value */
	d3.select('[data-heading]').text('Weekly downloads')
	d3.select('[data-total]').text(yAccessor(lastDatum))
})

Prevent the marker being clipped

If you hover on one of the highest peaks in the line graph, you might notice that the circular marker is being clipped at the top. That’s because we’ve mapped the domain to the full height of our SVG. At the highest point, the center of the circle will be positioned at a y co-ordinate of 0. To fix that, we can add a margin to the top of our chart equivalent to the radius of the marker. Let’s modify our dimensions object:

const dimensions = {
	width: 600,
	height: 200,
	marginTop: 8
}

Then, in our yScale function, we’ll use a the marginTop value for our range instead of 0:

const yScale = d3.scaleLinear()
	.domain(yDomain)
	.range([dimensions.height, dimensions.marginTop]

Now our marker should no longer be clipped.

Color

Now that we have all the functionality in place, let’s turn our attention to customising our chart a little more. I’ve added some styles in the demo to replicate the layout of the NPM chart (although feel free to adapt the layout as you wish!). We’re going to add some bespoke color scheme options, which can be toggled with radio buttons. First we’ll add the radio buttons in our HTML:

<ul class="controls-list">
	<li>
		<input type="radio" name="color scheme" value="purple" id="c-purple">
		<label for="c-purple">Purple</label>
	</li>
	<li>
		<input type="radio" name="color scheme" value="red" id="c-red">
		<label for="c-red">Red</label>
	</li>
	<li>
		<input type="radio" name="color scheme" value="blue" id="c-blue">
		<label for="c-blue">Blue</label>
	</li>
</ul>

We’re going to use CSS custom properties to easily switch between color schemes. First we’ll define some initial colors in our CSS, using custom properties for the fill and stroke colors of our chart, and for the heading color (the “Weekly downloads” title):

:root {
	--textHeadingColor: rgb(117, 117, 117);
	--fill: hsl(258.1, 100%, 92%);
	--stroke: hsl(258.1, 100%, 66.9%);
}

Now, where we’re using named colors in our JS, we’ll swap these out for custom properties. For the marker line and circle, we can additionally include a default value. In some of our color schemes we might want to give these a different color. But if the --marker custom property isn’t defined it’ll fall back to the stroke color.

const area = svg
	.append('path')
	.datum(data)
	/* ...other attributes */
	.attr('fill', 'var(--fill)')
		
const line = svg
	.append('path')
	.datum(data)
	/* ...other attributes */
	.attr('stroke', 'var(--stroke)')
	
const markerLine = svg
	.append('line')
	/* ...other attributes */
	.attr('stroke', 'var(--marker, var(--stroke))')
	
const markerDot = svg
	.append('circle')
	/* ...other attributes */
	.attr('fill', 'var(--marker, var(--stroke))')

Now we’ll add a function to toggle the colors when the user clicks a radio button by appending a class to the body. We could do this with regular JS, but as we’re learning D3 let’s do it the D3 way!

First we’ll select our radio buttons using D3’s selectAll method:

const inputs = d3.selectAll('input[type="radio"]')

When the user selects an option, we’ll first want to remove any color scheme classes that are already appended, so let’s create an array of color classes to check for. selectAll returns a D3 selection object rather than the actual DOM nodes. But we can use nodes() in D3 to select the elements, then map over them to return the input values (which will be the classes to append):

const colors = inputs.nodes().map((input) => {
	return input.value
})

Now we can add an event listener to our input wrapper, using D3’s on() method (using select to select the element). This will remove any pre-existing color scheme classes, and append the class related to the selected input:

d3.select('.controls-list')
	.on('click', (e) => {
		const { value, checked } = e.target
		
		if (!value || !checked) return
	
		document.body.classList.remove(...colors)
		document.body.classList.add(value)
	})

All that remains is to add some CSS for the red and blue color schemes (purple will be the default):

.red {
	--stroke: hsl(338 100% 50%);
	--fill: hsl(338 100% 83%);
	--marker: hsl(277 100% 50%);
	--textHeadingColor: hsl(277 5% 9%);
	
	background-color: hsl(338 100% 93%);
	color: hsl(277 5% 9%);
}

.blue {
	--stroke: hsl(173 82% 46%);
	--fill: hsl(173 82% 56% / 0.2);
	--marker: hsl(183 100% 99%);
	--textHeadingColor: var(--stroke);
	
	background-color: hsl(211 16% 12%);
	color: white;
	color-scheme: dark;
}

As a nice little extra touch, we can use the new CSS accent-color property to ensure that our radio buttons adopt the stroke color from the color scheme in supporting browsers too:

.controls-list {
	accent-color: var(--stroke);
}

As our blue color scheme has a dark background we can use colour-scheme: dark to give the checkboxes a matching dark background.

Performance

I mentioned earlier that the D3 library is quite extensive, and we’re only using parts of it. To keep our bundle size as small as possible, we can elect to only import the modules we need. We can modify the import statements at the top of our file, for example:

import { line, area, curveBumpX } from 'd3-shape'
import { select, selectAll } from 'd3-selection'
import { timeFormat } from 'd3-time-format'
import { extent } from 'd3-array'

The we just need to modify any d3 references in our code:

/* Previously: */
const xDomain = d3.extent(data, xAccessor)

/* Modified: */
const xDomain = extent(data, xAccessor)

See the Pen
D3 sparkline chart
by Michelle Barker (@michellebarker)
on CodePen.0

Resources

The post Building an Interactive Sparkline Graph with D3 appeared first on Codrops.

Building a Scrollable and Draggable Timeline with GSAP

We’re going to build a timeline showing albums released by the rock band Radiohead. The subject of our timeline doesn’t really matter — the main thing is a series of events that happen over a number of dates — so feel free to pick your own subject matter to make it more personal to you!

We’ll have a timeline along the top of our webpage showing our dates, and a number of full-width sections where our content for each of those dates will live. Dragging the horizontal timeline should scroll the page to the appropriate place in the content, and likewise scrolling the page will cause our timeline to update. Additionally, clicking any of the links in the timeline will allow the user to jump straight to the relevant section. This means we have three different methods for navigating our page — and they all have to sync perfectly with one another.

Three stages showing the horizontal timeline moving from right to left while the page itself is scrolled vertically

We’re going to walk through the steps for creating our timeline. Feel free to jump straight to the final demo if you want to get stuck into the code, or use this starter Codepen, which includes some simple initial styles so you can concentrate on the JS.

Markup

Let’s start with our HTML. As this is going to be our main page navigation, we’ll use the <nav> element. Inside this, we have a marker, which we’ll style with CSS to indicate the position on the timeline. We also have a <div> with a class of nav__track, which will be our draggable trigger. It houses our list of navigation links.

<nav>
	<!--Shows our position on the timeline-->
	<div class="marker"></div>
	
	<!--Draggable element-->
	<div class="nav__track" data-draggable>
		<ul class="nav__list">
			<li>
				<a href="#section_1" class="nav__link" data-link><span>1993</span></a>
			</li>
			<li>
				<a href="#section_2" class="nav__link" data-link><span>1995</span></a>
			</li>
			<li>
				<a href="#section_3" class="nav__link" data-link><span>1997</span></a>
			</li>
			<!--More list items go here-->
		</ul>
	</div>
</nav>

Below our nav, we have the main content of our page, which includes a number of sections. We’ll give each one an id that corresponds to one of the links in the navigation. That way, when a user clicks a link they’ll be scrolled to the relevant place in the content — no JS required.

We’ll also set each one a custom property corresponding to the section’s index. This is optional, but can be useful for styling. We won’t worry about the content of our sections for now.

<main>
	<section id="section_1" style="--i: 0"></section>
	<section id="section_2" style="--i: 1"></section>
	<section id="section_3" style="--i: 2"></section>
	<!--More list sections go here-->
</main>

CSS

Next we’ll move onto our basic layout. We’ll give each section a min-height of 100vh. We can also give them a background color, to make it obvious when we’re scrolling through the sections. We can use the custom property we set in the last step in combination with the hsl() color function to give each one a unique hue:

section {
	--h: calc(var(--i) * 30);
	
	min-height: 100vh;
	background-color: hsl(var(--h, 0) 75% 50%);
}

We’ll position our nav along the top of the page and give it a fixed position.

nav {
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;
}

While the nav itself will be fixed (to ensure it remains visible as the user scrolls), the track inside it will be draggable. This will need to be wider than the viewport, as we want the user to be able to drag it all the way along. It also needs some padding, as we’ll need the user to be able to drag on the area after our items have ended, so that they can move the track all the way along. To ensure our track has a suitable width at all viewport sizes, we can use the max() function. This returns the largest of two comma-separated values. At narrow viewport widths our track will be a minimum of 200rem wide, ensuring that our items retain a pleasing distance from one another. At larger viewport widths the track will be 200% wide which, accounting for the padding, means our items will be dispersed evenly along the width of the viewport when position them with flexbox.

.nav__track {
	position: relative;
	min-width: max(200rem, 200%);
	padding: 1.5rem max(100rem, 100%) 0 0;
	height: 6rem;
}

.nav__list {
	/* Remove default list styles */
	list-style: none;
	margin: 0;
	padding: 0;
	
	/* Position items horizontally */
	display: flex;
	justify-content: space-between;
}

We can also style our marker, which will show the user the current position on the timeline. For now we’ll add a simple dot, which we’ll position 4rem from the left. If we also set a width of 4rem on our navigation items, this should center the first navigation item below the marker on the left of the viewport.

.marker {
	position: fixed;
	top: 1.75rem;
	left: 4rem;
	width: 1rem;
	height: 1rem;
	transform: translate3d(-50%, 0, 0);
	background: blue;
	border-radius: 100%;
	z-index: 2000;
}

.nav__link {
	position: relative;
	display: block;
	min-width: 8rem;
	text-align: center;
}

You might want to add some custom styling to the track like I’ve done in the demo, but this should be enough for us to move onto the next step.

The JavaScript

Installing plugins

We’ll be using the GSAP (Greensock) core package and its ScrollTrigger and Draggable plugins. There are many ways to install GSAP — check out this page for options. If you go for the NPM option, you’ll’ll need to import the modules at the top of the JS file, and register the plugins:

import gsap from 'gsap'
import ScrollTrigger from 'gsap/ScrollTrigger'
import Draggable from 'gsap/Draggable'

gsap.registerPlugin(ScrollTrigger, Draggable)

Creating the animation timeline

We want the track to move horizontally when the user scrolls the page or drags the timeline itself. We could allow the user to drag the marker instead, but this wouldn’t work well if we had more navigation items than would fit horizontally in the viewport. If we keep the marker stationary while moving the track, it gives us a lot more flexibility.

The first thing we’ll do is create an animation timeline with GSAP. Our timeline is quite simple: it will include just a single tween to move the track to the left, until the last item is just below the marker we positioned earlier. We’ll need to use the width of the last nav item in some other places, so we’ll create a function we can call whenever we need this value. We can use GSAP’s toArray utility function to set an array of our nav links as a variable:

const navLinks = gsap.utils.toArray('[data-link]')

const lastItemWidth = () => navLinks[navLinks.length - 1].offsetWidth

Now we can use that to calculate the x value in our tween:

const track = document.querySelector('[data-draggable]')

const tl = gsap.timeline()
	.to(track, {
		x: () => {
			return ((track.offsetWidth * 0.5) - lastItemWidth()) * -1
		},
		ease: 'none' // important!
	})

Easing

We’re also removing the easing on our timeline tween. This is very important, as the movement will be tied to the scroll position, and easing would play havoc with our calculations later on!

Creating the ScrollTrigger instance

We’re going to create a ScrollTrigger instance, which will trigger the timeline animation. We’ll set the scrub value as 0. This will cause our animation to play at the rate the user scrolls. A value other than 0 creates a lag between the scroll action and the animation, which can work nicely in some instances, but won’t serve us well here.

const st = ScrollTrigger.create({
	animation: tl,
	scrub: 0
})

Our animation timeline will start playing as soon as the user starts scrolling from the top of the page, and end when the page is scrolled all the way to the bottom. If you need anything different, you’ll need to specify start and end values on the ScrollTrigger instance too. (See the ScrollTrigger documentation for more details).

Creating the Draggable instance

Now we’ll create a Draggable instance. We’ll pass in our track as the first argument (the element we want to make draggable). In our options (the second argument) we’ll specify <em>x</em> for the type, as we only want it to be dragged horizontally. We can also set inertia to true. This is optional, as it requires the Inertia plugin, a premium plugin for Greensock members (but free to use on Codepen). Using Inertia mean that when the user lets go after dragging the element, it will glide to a stop in a more naturalistic way. It’s not strictly necessary for this demo, but I prefer the effect.

const draggableInstance = Draggable.create(track, {
	type: 'x',
	inertia: true
})

Next we want to set the bounds, otherwise there’s a danger the element could be dragged right off the screen. We’ll set the minimum and maximum values the element can be dragged. We don’t want it to be dragged any further to the right than its current start position, so we’ll set minX as 0. The maxX value will in fact need to be the same value as used in our timeline tween — so how about we make a function for that:

const getDraggableWidth = () => {
	return (track.offsetWidth * 0.5) - lastItemWidth()
}

const draggableInstance = Draggable.create(track, {
	type: 'x',
	inertia: true,
	bounds: {
		minX: 0,
		maxX: getDraggableWidth() * -1
	},
	edgeResistance: 1 // Don’t allow any dragging beyond the bounds
})

We’ll need to set edgeResistance to 1, which will prevent any dragging at all beyond our specified bounds.

Putting them together

Now, for the technical part! We’re going to programmatically scroll the page when the user drags the element. The first thing to do is to disable the ScrollTrigger instance when the user starts dragging the track, and re-enable it when the drag ends. We can use the onDragStart and onDragEnd options on our Draggable instance to do that:

const draggableInstance = Draggable.create(track, {
	type: 'x',
	inertia: true,
	bounds: {
		minX: 0,
		maxX: getDraggableWidth() * -1
	},
	edgeResistance: 1,
	onDragStart: () => st.disable(),
	onDragEnd: () => st.enable()
})

Then we’ll write a function that gets called on drag. We’ll get the offset position of our draggable element (using getBoundingClientRect()). We’ll also need to know the total scrollable height of the page, which will be the document height minus the viewport height. Let’s create a function for this, to keep it tidy.

const getUseableHeight = () => document.documentElement.offsetHeight - window.innerHeight

We’ll use GSAP’s mapRange() utility function to find the relative scroll position (see the documentation), and call the scroll() method on the ScrollTrigger instance to update the scroll position on drag:

const draggableInstance = Draggable.create(track, {
	type: 'x',
	inertia: true,
	bounds: {
		minX: 0,
		maxX: getDraggableWidth() * -1
	},
	edgeResistance: 1,
	onDragStart: () => st.disable(),
	onDragEnd: () => st.enable(),
	onDrag: () => {
		const left = track.getBoundingClientRect().left * -1
		const width = getDraggableWidth()
		const useableHeight = getUseableHeight()
		const y = gsap.utils.mapRange(0, width, 0, useableHeight, left)
		
    st.scroll(y)
  }
})

As we’re using the Inertia plugin, we’ll want to call the same function during the “throw” part of the interaction — after the user lets go of the element, but while it retains momentum. So let’s write it as a separate function we can call for both:

const updatePosition = () => {
	const left = track.getBoundingClientRect().left * -1
	const width = getDraggableWidth()
	const useableHeight = getUseableHeight()
	const y = gsap.utils.mapRange(0, width, 0, useableHeight, left)

	st.scroll(y)
}

const draggableInstance = Draggable.create(track, {
	type: 'x',
	inertia: true,
	bounds: {
		minX: 0,
		maxX: getDraggableWidth() * -1
	},
	edgeResistance: 1,
	onDragStart: () => st.disable(),
	onDragEnd: () => st.enable(),
	onDrag: updatePosition,
	onThrowUpdate: updatePosition
})

Now our scroll position and timeline track should be perfectly in sync when we scroll the page or drag the track.

Navigating on click

We also want users to be able to scroll to the desired section by clicking on any of the timeline links. We could do this with JS, but we don’t necessarily need to: CSS has a property that allows smooth scrolling within the page, and it’s supported in most modern browsers (Safari is currently the exception). All we need is this one line of CSS, and our users will be scrolled smoothly to the desired section on click:

html {
	scroll-behavior: smooth;
}

Accessibility

It’s good practice to consider users who may be sensitive to motion, so let’s include a prefers-reduced-motion media query to ensure that users who have specified a system-level preference for reduced motion will be jumped straight to the relevant section instead:

@media (prefers-reduced-motion: no-preference) {
	html {
		scroll-behavior: smooth;
	}
}

Our navigation currently presents a problem for users who navigate using a keyboard. When our nav overflows the viewport, some of our nav links are hidden from view, as they are offscreen. When the user tabs through the links, we need those links to be brought into view. We can attach an event listener to our track to get the scroll position of the corresponding section, and call scroll() on the ScrollTrigger instance, which will have the effect of moving the timeline too (keeping them both in sync):

track.addEventListener('keyup', (e) => {
	const id = e.target.getAttribute('href')
	
	/* Return if no section href or the user isn’t using the tab key */
	if (!id || e.key !== 'Tab') return
	
	const section = document.querySelector(id)
	
	/* Get the scroll position of the section */
	const y = section.getBoundingClientRect().top + window.scrollY
	
	/* Use the ScrollTrigger to scroll the window */
	st.scroll(y)
})

Calling scroll() also respects our users’ motion preferences — users with a reduced-motion preference will be jumped to the section instead of smoothly scrolled.

See the Pen
GSAP Draggable and ScrollTrigger timeline [Simple 1]
by Michelle Barker (@michellebarker)
on CodePen.0

Animating the sections

Our timeline should work pretty well now, but we don’t yet have any content. Let’s add a heading and image for each section, and animate them when the come into view. Here’s an example of the HTML for one section, which we can repeat for the other (adjusting the content as needed):

<main>
	<section id="section_1" style="--i: 0">
		<div class="container">
			<h2 class="section__heading">
				<span>1993</span>
				<span>Pablo Honey</span>
			</h2>
			<div class="section__image">
				<img src="https://assets.codepen.io/85648/radiohead_pablo-honey.jpg" width="1200" height="1200" />
			</div>
		</div>
	</section>
	<!--more sections-->
</main>

I’m using display: grid to position the heading and image in a pleasing arrangement — but feel free to position them as you like. We’ll just concentrate on the JS for this part.

Creating the timelines with GSAP

We’ll create a function called initSectionAnimation(). The first thing we’ll do is return early if our users prefer reduced motion. We can used a prefers-reduced-motion media query using the matchMedia method:

const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')

const initSectionAnimation = () => {
	/* Do nothing if user prefers reduced motion */
	if (prefersReducedMotion.matches) return
}

initSectionAnimation()

Next we’ll set our animation start state for each section:

const initSectionAnimation = () => {
	/* Do nothing if user prefers reduced motion */
	if (prefersReducedMotion.matches) return
	
	sections.forEach((section, index) => {
		const heading = section.querySelector('h2')
		const image = section.querySelector('.section__image')
		
		/* Set animation start state */
		gsap.set(heading, {
			opacity: 0,
			y: 50
		})
		gsap.set(image, {
			opacity: 0,
			rotateY: 15
		})
	}
}

Then we’ll create a new timeline for each section, adding ScrollTrigger to the timeline itself to control when the animation is played. We can do this directly this time, rather than creating a separate ScrollTrigger instance, as we don’t need this timeline to be connected to a draggable element. (This code is all within the forEach loop.) We’ll add some tweens to the timeline to animate the heading and image into view.

/* In the `forEach` loop: */

/* Create the section timeline */
const sectionTl = gsap.timeline({
	scrollTrigger: {
		trigger: section,
		start: () => 'top center',
		end: () => `+=${window.innerHeight}`,
		toggleActions: 'play reverse play reverse'
	}
})

/* Add tweens to the timeline */
sectionTl.to(image, {
	opacity: 1,
	rotateY: -5,
	duration: 6,
	ease: 'elastic'
})
.to(heading, {
	opacity: 1,
	y: 0,
	duration: 2
}, 0.5) // the heading tween will play 0.5 seconds from the start

By default our tweens will play one after the other. But I’m using the position parameter to specify that the heading tween should play 0.5 seconds from the beginning of the timeline, so our animations overlap.

Here’s the complete demo in action:

See the Pen
GSAP Draggable and ScrollTrigger timeline [FINAL]
by Michelle Barker (@michellebarker)
on CodePen.0

The post Building a Scrollable and Draggable Timeline with GSAP appeared first on Codrops.

Creating 3D Characters in Three.js

Three.js is a JavaScript library for drawing in 3D with WebGL. It enables us to add 3D objects to a scene, and manipulate things like position and lighting. If you’re a developer used to working with the DOM and styling elements with CSS, Three.js and WebGL can seem like a whole new world, and perhaps a little intimidating! This article is for developers who are comfortable with JavaScript but relatively new to Three.js. Our goal is to walk through building something simple but effective with Three.js — a 3D animated figure — to get a handle on the basic principles, and demonstrate that a little knowledge can take you a long way!

Setting the scene

In web development we’re accustomed to styling DOM elements, which we can inspect and debug in our browser developer tools. In WebGL, everything is rendered in a single <canvas> element. Much like a video, everything is simply pixels changing color, so there’s nothing to inspect. If you inspected a webpage rendered entirely with WebGL, all you would see is a <canvas> element. We can use libraries like Three.js to draw on the canvas with JavaScript.

Basic principles

First we’re going to set up the scene. If you’re already comfortable with this you can skip over this part and jump straight to the section where we start creating our 3D character.

We can think of our Three.js scene as a 3D space in which we can place a camera, and an object for it to look at.

Drawing of a transparent cube, with a smaller cube inside, showing the x, y and z axis and center co-ordinates
We can picture our scene as a giant cube, with objects placed at the center. In actual fact, it extends infinitely, but there is a limit to how much we can see.

First of all we need to create the scene. In our HTML we just need a <canvas> element:

<canvas data-canvas></canvas>

Now we can create the scene with a camera, and render it on our canvas in Three.js:

const canvas = document.querySelector('[data-canvas]')

// Create the scene
const scene = new THREE.Scene()

// Create the camera
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 1000)
scene.add(camera)

// Create the renderer
const renderer = new THREE.WebGLRenderer({ canvas })

// Render the scene
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.render(scene, camera)

For brevity, we won’t go into the precise details of everything we’re doing here. The documentation has much more detail about creating a scene and the various camera attributes. However, the first thing we’ll do is move the position of our camera. By default, anything we add to the scene is going to be placed at co-ordinates (0, 0, 0) — that is, if we imagine the scene itself as a cube, our camera will be placed right in the center. Let’s place our camera a little further out, so that our camera can look at any objects placed in the center of the scene.

Showing the camera looking towards the center of the scene
Moving the camera away from the center allows us to see the objects placed in the center of the scene.

We can do this by setting the z position of the camera:

camera.position.z = 5

We won’t see anything yet, as we haven’t added any objects to the scene. Let’s add a cube to the scene, which will form the basis of our figure.

3D shapes

Objects in Three.js are known as meshes. In order to create a mesh, we need two things: a geometry and a material. Geometries are 3D shapes. Three.js has a selection of geometries to choose from, which can be manipulated in different ways. For the purpose of this tutorial — to see what interesting scenes we can make with just some basic principles — we’re going to limit ourselves to only two geometries: cubes and spheres.

Let’s add a cube to our scene. First we’ll define the geometry and material. Using Three.js BoxGeometry, we pass in parameters for the x, y and z dimensions.

// Create a new BoxGeometry with dimensions 1 x 1 x 1
const geometry = new THREE.BoxGeometry(1, 1, 1)

For the material we’ll choose MeshLambertMaterial, which reacts to light and shade but is more performant than some other materials.

// Create a new material with a white color
const material = new THREE.MeshLambertMaterial({ color: 0xffffff })

Then we create the mesh by combining the geometry and material, and add it to the scene:

const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)

Unfortunately we still won’t see anything! That’s because the material we’re using depends on light in order to be seen. Let’s add a directional light, which will shine down from above. We’ll pass in two arguments: 0xffffff for the color (white), and the intensity, which we’ll set to 1.

const lightDirectional = new THREE.DirectionalLight(0xffffff, 1)
scene.add(lightDirectional)
Light shining down from above at position 0, 1, 0
By default, the light points down from above

If you’ve followed all the steps so far, you still won’t see anything! That’s because the light is pointing directly down at our cube, so the front face is in shadow. If we move the z position of the light towards the camera and off-center, we should now see our cube.

const lightDirectional = new THREE.DirectionalLight(0xffffff, 1)
scene.add(lightDirectional)

// Move the light source towards us and off-center
lightDirectional.position.x = 5
lightDirectional.position.y = 5
lightDirectional.position.z = 5
Light position at 5, 5, 5
Moving the light gives us a better view

We can alternatively set the position on the x, y and z axis simultaneously by calling set():

lightDirectional.position.set(5, 5, 5)

We’re looking at our cube straight on, so only one face can be seen. If we give it a little bit of rotation, we can see the other faces. To rotate an object, we need to give it a rotation angle in [radians](). I don’t know about you, but I don’t find radians very easy to visualize, so I prefer to use a JS function to convert from degrees:

const degreesToRadians = (degrees) => {
	return degrees * (Math.PI / 180)
}

mesh.rotation.x = degreesToRadians(30)
mesh.rotation.y = degreesToRadians(30)

We can also add some ambient light (light that comes from all directions) with a color tint, which softens the effect slightly end ensures the face of the cube turned away from the light isn’t completely hidden in shadow:

const lightAmbient = new THREE.AmbientLight(0x9eaeff, 0.2)
scene.add(lightAmbient)

Now that we have our basic scene set up, we can start to create our 3D character. To help you get started I’ve created a boilerplate which includes all the set-up work we’ve just been through, so that you can jump straight to the next part if you wish.

Creating a class

The first thing we’ll do is create a class for our figure. This will make it easy to add any number of figures to our scene by instantiating the class. We’ll give it some default parameters, which we’ll use later on to position our character in the scene.

class Figure {
	constructor(params) {
		this.params = {
			x: 0,
			y: 0,
			z: 0,
			ry: 0,
			...params
		}
	}
}

Groups

In our class constructor, let’s create a Three.js group and add it to our scene. Creating a group allows us to manipulate several geometries as one. We’re going to add the different elements of our figure (head, body, arms, etc.) to this group. Then we can position, scale or rotate the figure anywhere in our scene without having to concern ourselves with individually positioning those parts individually every time.

class Figure {
	constructor(params) {
		this.params = {
			x: 0,
			y: 0,
			z: 0,
			ry: 0,
			...params
		}
		
		this.group = new THREE.Group()
		scene.add(this.group)
	}
}

Creating the body parts

Next let’s write a function to render the body of our figure. It’ll be much the same as the way we created a cube earlier, except, we’ll make it a little taller by increasing the size on the y axis. (While we’re at it, we can remove the lines of code where we created the cube earlier, to start with a clear scene.) We already have the material defined in our codebase, and don’t need to define it within the class itself.

Instead of adding the body to the scene, we instead add it to the group we created.

const material = new THREE.MeshLambertMaterial({ color: 0xffffff })

class Figure {
	constructor(params) {
		this.params = {
			x: 0,
			y: 0,
			z: 0,
			ry: 0,
			...params
		}
		
		this.group = new THREE.Group()
		scene.add(this.group)
	}
	
	createBody() {
		const geometry = new THREE.BoxGeometry(1, 1.5, 1)
		const body = new THREE.Mesh(geometry, material)
		this.group.add(body)
	}
}

We’ll also write a class method to initialize the figure. So far it will call only the createBody() method, but we’ll add others shortly. (This and all subsequent methods will be written inside our class declaration, unless otherwise specified.)

createBody() {
	const geometry = new THREE.BoxGeometry(1, 1.5, 1)
	const body = new THREE.Mesh(geometry, material)
	this.group.add(body)
}
	
init() {
	this.createBody()
}

Adding the figure to the scene

At this point we’ll want to render our figure in our scene, to check that everything’s working. We can do that by instantiating the class.

const figure = new Figure()
figure.init()

Next we’ll write a similar method to create the head of our character. We’ll make this a cube, slightly larger than the width of the body. We’ll also need to adjust the position so it’s just above the body, and call the function in our init() method:

createHead() {
	const geometry = new THREE.BoxGeometry(1.4, 1.4, 1.4)
	const head = new THREE.Mesh(geometry, material)
	this.group.add(head)
	
	// Position it above the body
	head.position.y = 1.65
}

init() {
	this.createBody()
	this.createHead()
}

You should now see a narrower cuboid (the body) rendered below the first cube (the head).

Adding the arms

Now we’re going to give our character some arms. Here’s where things get slightly more complex. We’ll add another method to our class called createArms(). Again, we’ll define a geometry and a mesh. The arms will be long, thin cuboids, so we’ll pass in our desired dimensions for these.

As we need two arms, we’ll create them in a for loop.

createArms() {
	for(let i = 0; i < 2; i++) {
		const geometry = new THREE.BoxGeometry(0.25, 1, 0.25)
		const arm = new THREE.Mesh(geometry, material)
		
		this.group.add(arm)
	}
}

We don’t need to create the geometry in the for loop, as it will be the same for each arm.

Don’t forget to call the function in our init() method:

init() {
	this.createBody()
	this.createHead()
	this.createArms()
}

We’ll also need to position each arm either side of the body. I find it helpful here to create a variable m (for multiplier). This helps us position the left arm in the opposite direction on the x axis, with minimal code. (We’ll also use it rotate the arms in a moment too.)

createArms() {
	for(let i = 0; i < 2; i++) {
		const geometry = new THREE.BoxGeometry(0.25, 1, 0.25)
		const arm = new THREE.Mesh(geometry, material)
		const m = i % 2 === 0 ? 1 : -1
		
		this.group.add(arm)
		
		arm.position.x = m * 0.8
		arm.position.y = 0.1
	}
}

Additionally, we can rotate the arms in our for loop, so they stick out at a more natural angle (as natural as a cube person can be!):

arm.rotation.z = degreesToRadians(30 * m)
Figure with co-ordinate system overlaid
If our figure is placed in the center, the arm on the left will be positioned at the negative equivalent of the x-axis position of the arm on the right

Pivoting

When we rotate the arms you might notice that they rotate from a point of origin in the center. It can be hard to see with a static demo, but try moving the slider in this example.

See the Pen
ThreeJS figure arm pivot example (default pivot from center)
by Michelle Barker (@michellebarker)
on CodePen.0

We can see that the arms don’t move naturally, at an angle from the shoulder, but instead the entire arm rotates from the center. In CSS we would simply set the transform-origin. Three.js doesn’t have this option, so we need to do things slightly differently.

Two figures, the leftmost with an arm that pivots from the center, the rightmost with an arm that pivots from the top left
The figure on the right has arms that rotate from the top, for a more natural effect

Our steps are as follows for each arm:

  1. Create a new Three.js group.
  2. Position the group at the “shoulder” of our figure (or the point from which we want to rotate).
  3. Create a new mesh for the arm and position it relative to the group.
  4. Rotate the group (instead of the arm).

Let’s update our createArms() function to follow these steps. First we’ll create the group for each arm, add the arm mesh to the group, and position the group roughly where we want it:

createArms() {
	const geometry = new THREE.BoxGeometry(0.25, 1, 0.25)
	
	for(let i = 0; i < 2; i++) {
		const arm = new THREE.Mesh(geometry, material)
		const m = i % 2 === 0 ? 1 : -1
		
		// Create group for each arm
		const armGroup = new THREE.Group()
		
		// Add the arm to the group
		armGroup.add(arm)
		
		// Add the arm group to the figure
		this.group.add(armGroup)
		
		// Position the arm group
		armGroup.position.x = m * 0.8
		armGroup.position.y = 0.1
	}
}

To assist us with visualizing this, we can add one of Three.js’s built-in helpers to our figure. This creates a wireframe showing the bounding box of an object. It’s useful to help us position the arm, and once we’re done we can remove it.

// Inside the `for` loop:
const box = new THREE.BoxHelper(armGroup, 0xffff00)
this.group.add(box)

To set the transform origin to the top of the arm rather than the center, we then need to move the arm (within the group) downwards by half of its height. Let’s create a variable for height, which we can use when creating the geometry:

createArms() {
	// Set the variable
	const height = 1
	const geometry = new THREE.BoxGeometry(0.25, height, 0.25)
	
	for(let i = 0; i < 2; i++) {
		const armGroup = new THREE.Group()
		const arm = new THREE.Mesh(geometry, material)
		
		const m = i % 2 === 0 ? 1 : -1
		
		armGroup.add(arm)
		this.group.add(armGroup)
		
		// Translate the arm (not the group) downwards by half the height
		arm.position.y = height * -0.5
		
		armGroup.position.x = m * 0.8
		armGroup.position.y = 0.6
		
		// Helper
		const box = new THREE.BoxHelper(armGroup, 0xffff00)
		this.group.add(box)
	}
}

Then we can rotate the arm group.

// In the `for` loop
armGroup.rotation.z = degreesToRadians(30 * m)

In this demo, we can see that the arms are (correctly) being rotated from the top, for a more realistic effect. (The yellow is the bounding box.)

See the Pen
ThreeJS figure arm pivot example (using group)
by Michelle Barker (@michellebarker)
on CodePen.0

Eyes

Next we’re going to give our figure some eyes, for which we’ll use the Sphere geometry in Three.js. We’ll need to pass in three parameters: the radius of the sphere, and the number of segments for the width and height respectively (defaults shown here).

const geometry = new THREE.SphereGeometry(1, 32, 16)

As our eyes are going to be quite small, we can probably get away with fewer segments, which is better for performance (fewer calculations needed).

Let’s create a new group for the eyes. This is optional, but it helps keep things neat. If we need to reposition the eyes later on, we only need to reposition the group, rather than both eyes individually. Once again, let’s create the eyes in a for loop and add them to the group. As we want the eyes to be a different color from the body, we can define a new material:

createEyes() {
	const eyes = new THREE.Group()
	const geometry = new THREE.SphereGeometry(0.15, 12, 8)
	
	// Define the eye material
	const material = new THREE.MeshLambertMaterial({ color: 0x44445c })
	
	for(let i = 0; i < 2; i++) {
		const eye = new THREE.Mesh(geometry, material)
		const m = i % 2 === 0 ? 1 : -1
		
		// Add the eye to the group
		eyes.add(eye)
		
		// Position the eye
		eye.position.x = 0.36 * m
	}
}

We could add the eye group directly to the figure. However, if we decide we want to move the head later on, it would be better if the eyes moved with it, rather than being positioned entirely independently! For that, we need to modify our createHead() method to create another group, comprising both the main cube of the head, and the eyes:

createHead() {
	// Create a new group for the head
	this.head = new THREE.Group()
	
	// Create the main cube of the head and add to the group
	const geometry = new THREE.BoxGeometry(1.4, 1.4, 1.4)
	const headMain = new THREE.Mesh(geometry, material)
	this.head.add(headMain)
	
	// Add the head group to the figure
	this.group.add(this.head)
	
	// Position the head group
	this.head.position.y = 1.65
	
	// Add the eyes by calling the function we already made
	this.createEyes()
}

In the createEyes() method we then need to add the eye group to the head group, and position them to our liking. We’ll need to position them forwards on the z axis, so they’re not hidden inside the cube of the head:

// in createEyes()
this.head.add(eyes)

// Move the eyes forwards by half of the head depth - it might be a good idea to create a variable to do this!
eyes.position.z = 0.7

Legs

Lastly, let’s give our figure some legs. We can create these in much the same way as the eyes. As they should be positioned relative to the body, we can create a new group for the body in the same way that we did with the head, then add the legs to it:

createLegs() {
	const legs = new THREE.Group()
	const geometry = new THREE.BoxGeometry(0.25, 0.4, 0.25)
	
	for(let i = 0; i < 2; i++) {
		const leg = new THREE.Mesh(geometry, material)
		const m = i % 2 === 0 ? 1 : -1
		
		legs.add(leg)
		leg.position.x = m * 0.22
	}
	
	this.group.add(legs)
	legs.position.y = -1.15
	
	this.body.add(legs)
}

Positioning in the scene

If we go back to our constructor, we can position our figure group according to the parameters:

class Figure {
	constructor(params) {
		this.params = {
			x: 0,
			y: 0,
			z: 0,
			ry: 0,
			...params
		}
		
		this.group.position.x = this.params.x
		this.group.position.y = this.params.y
		this.group.position.z = this.params.z
		this.group.rotation.y = this.params.ry
	}
}

Now, passing in different parameters enables us to position it accordingly. For example, we can give it a bit of rotation, and adjust its x and y position:

const figure = new Figure({ 
	x: -4,
	y: -2,
	ry: degreesToRadians(35)
})
figure.init()

Alternatively, if we want to center the figure within the scene, we can use the Three.js Box3 function, which computes the bounding box of the figure group. This line will center the figure horizontally and vertically:

new THREE.Box3().setFromObject(figure.group).getCenter(figure.group.position).multiplyScalar(-1)

Making it generative

At the moment our figure is all one color, which doesn’t look particularly interesting. We can add a bit more color, and take the extra step of making it generative, so we get a new color combination every time we refresh the page! To do this we’re going to use a function to randomly generate a number between a minimum and a maximum. This is one I’ve borrowed from George Francis, which allows us to specify whether we want an integer or a floating point value (default is an integer).

const random = (min, max, float = false) => {
  const val = Math.random() * (max - min) + min

  if (float) {
    return val
  }

  return Math.floor(val)
}

Let’s define some variables for the head and body in our class constructor. Using the random() function, we’ll generate a value for each one between 0 and 360:

class Figure {
	constructor(params) {
		this.headHue = random(0, 360)
		this.bodyHue = random(0, 360)
	}
}

I like to use HSL when manipulating colors, as it gives us a fine degree of control over the hue, saturation and lightness. We’re going to define the material for the head and body, generating different colors for each by using template literals to pass the random hue values to the hsl color function. Here I’m adjusting the saturation and lightness values, so the body will be a vibrant color (high saturation) while the head will be more muted:

class Figure {
	constructor(params) {
		this.headHue = random(0, 360)
		this.bodyHue = random(0, 360)
		
		this.headMaterial = new THREE.MeshLambertMaterial({ color: `hsl(${this.headHue}, 30%, 50%` })
		this.bodyMaterial = new THREE.MeshLambertMaterial({ color: `hsl(${this.bodyHue}, 85%, 50%)` })
	}
}

Our generated hues range from 0 to 360, a full cycle of the color wheel. If we want to narrow the range (for a limited color palette), we could select a lower range between the minimum and maximum. For example, a range between 0 and 60 would select hues in the red, orange and yellow end of the spectrum, excluding greens, blues and purples.

We could similarly generate values for the lightness and saturation if we choose to.

Now we just need to replace any reference to material with this.headMaterial or this.bodyMaterial to apply our generative colors. I’ve chosen to use the head hue for the head, arms and legs.

See the Pen
ThreeJS figure (generative)
by Michelle Barker (@michellebarker)
on CodePen.0

We could use generative parameters for much more than just the colors. In this demo I’m generating random values for the size of the head and body, the length of the arms and legs, and the size and position of the eyes.

See the Pen
ThreeJS figure random generated
by Michelle Barker (@michellebarker)
on CodePen.0

Animation

Part of the fun of working with 3D is having our objects move in a three-dimensional space and behave like objects in the real world. We can add a bit of animation to our 3D figure using the Greensock animation library (GSAP).

GSAP is more commonly used to animate elements in the DOM. As we’re not animating DOM elements in this case, it requires a different approach. GSAP doesn’t require an element to animate — it can animate JavaScript objects. As one post in the GSAP forum puts it, GSAP is just “changing numbers really fast”.

We’ll let GSAP do the work of changing the parameters of our figure, then re-render our figure on each frame. To do this, we can use GSAP’s ticker method, which uses requestAnimationFrame. First, let’s animate the ry value (our figure’s rotation on the y axis). We’ll set it to repeat infinitely, and the duration to 20 seconds:

gsap.to(figure.params, {
	ry: degreesToRadians(360),
	repeat: -1,
	duration: 20
})

We won’t see any change just yet, as we aren’t re-rendering our scene. Let’s now trigger a re-render on every frame:

gsap.ticker.add(() => {
	// Update the rotation value
	figure.group.rotation.y = this.params.ry
	
	// Render the scene
	renderer.setSize(window.innerWidth, window.innerHeight)
	renderer.render(scene, camera)
})

Now we should see the figure rotating on its y axis in the center of the scene. Let’s give him a little bounce action too, by moving him up and down and rotating the arms. First of all we’ll set his starting position on the y axis to be a little further down, so he’s not bouncing off screen. We’ll set yoyo: true on our tween, so that the animation repeats in reverse (so our figure will bounce up and down):

// Set the starting position
gsap.set(figure.params, {
	y: -1.5
})

// Tween the y axis position and arm rotation
gsap.to(figure.params, {
	y: 0,
	armRotation: degreesToRadians(90),
	repeat: -1,
	yoyo: true,
	duration: 0.5
})

As we need to update a few things, let’s create a method called bounce() on our Figure class, which will handle the animation. We can use it to update the values for the rotation and position, then call it within our ticker, to keep things neat:

/* In the Figure class: */
bounce() {
	this.group.rotation.y = this.params.ry
	this.group.position.y = this.params.y
}

/* Outside of the class */
gsap.ticker.add(() => {
	figure.bounce()
	
	// Render the scene
	renderer.setSize(window.innerWidth, window.innerHeight)
	renderer.render(scene, camera)
})

To make the arms move, we need to do a little more work. In our class constructor, let’s define a variable for the arms, which will be an empty array:

class Figure {
	constructor(params) {
		this.arms = []
	}
}

In our createArms() method, in addition to our code, we’ll push each arm group to the array:

createArms() {
	const height = 0.85
	
	for(let i = 0; i < 2; i++) {
		/* Other code for creating the arms.. */
		
		// Push to the array
		this.arms.push(armGroup)
	}
}

Now we can add the arm rotation to our bounce() method, ensuring we rotate them in opposite directions:

bounce() {
	// Rotate the figure
	this.group.rotation.y = this.params.ry
	
	// Bounce up and down
	this.group.position.y = this.params.y
	
	// Move the arms
	this.arms.forEach((arm, index) => {
		const m = index % 2 === 0 ? 1 : -1
		arm.rotation.z = this.params.armRotation * m
	})
}

Now we should see our little figure bouncing, as if on a trampoline!

See the Pen
ThreeJS figure with GSAP
by Michelle Barker (@michellebarker)
on CodePen.0

Wrapping up

There’s much, much more to Three.js, but we’ve seen that it doesn’t take too much to get started building something fun with just the basic building blocks, and sometimes limitation breeds creativity! If you’re interested in exploring further, I recommend the following resources.

Resources

The post Creating 3D Characters in Three.js appeared first on Codrops.

Trigonometry in CSS and JavaScript: Beyond Triangles

In the previous article we looked at how to clip an equilateral triangle with trigonometry, but what about some even more interesting geometric shapes?

This article is the 3rd part in a series on Trigonometry in CSS and JavaScript:

  1. Introduction to Trigonometry
  2. Getting Creative with Trigonometric Functions
  3. Beyond Triangles (this article)

Plotting regular polygons

A regular polygon is a polygon with all equal sides and all equal angles. An equilateral triangle is one, so too is a pentagon, hexagon, decagon, and any number of others that meet the criteria. We can use trigonometry to plot the points of a regular polygon by visualizing each set of coordinates as points of a triangle.

Polar coordinates

If we visualize a circle on an x/y axis, draw a line from the center to any point on the outer edge, then connect that point to the horizontal axis, we get a triangle.

A circle centrally positioned on an axis, with a line drawn along the radius to form a triangle

If we repeatedly rotated the line at equal intervals six times around the circle, we could plot the points of a hexagon.

A hexagon, made by drawing lines along the radius of the circle

But how do we get the x and y coordinates for each point? These are known as cartesian coordinates, whereas polar coordinates tell us the distance and angle from a particular point. Essentially, the radius of the circle and the angle of the line. Drawing a line from the center to the edge gives us a triangle where hypotenuse is equal to the circle’s radius.

Showing the triangle made by drawing a line from one of the vertices, with the hypotenuse equal to the radius, and the angle as 2pi divided by 6

We can get the angle in degrees by diving 360 by the number of vertices our polygon has, or in radians by diving 2pi radians. For a hexagon with a radius of 100, the polar coordinates of the uppermost point of the triangle in the diagram would be written (100, 1.0472rad) (r, θ).

An infinite number of points would enable us to plot a circle.

Polar to cartesian coordinates

We need to plot the points of our polygon as cartesian coordinates – their position on the x and y axis.

As we know the radius and the angle, we need to calculate the adjacent side length for the x position, and the opposite side length for the y position.

Showing the triangle superimposed on the hexagon, and the equations needed to calculate the opposite and adjacent sides.

Therefore we need Cosine for the former and Sine for the latter:

adjacent = cos(angle) * hypotenuse
opposite = sin(angle) * hypotenuse

We can write a JS function that returns an array of coordinates:

const plotPoints = (radius, numberOfPoints) => {

	/* step used to place each point at equal distances */
	const angleStep = (Math.PI * 2) / numberOfPoints

	const points = []

	for (let i = 1; i <= numberOfPoints; i++) {
		/* x & y coordinates of the current point */
		const x = Math.cos(i * angleStep) * radius
		const y = Math.sin(i * angleStep) * radius

		/* push the point to the points array */
		points.push({ x, y })
	}
	
	return points
}

We could then convert each array item into a string with the x and y coordinates in pixels, then use the join() method to join them into a string for use in a clip path:

const polygonCoordinates = plotPoints(100, 6).map(({ x, y }) => {
		return `${x}px ${y}px`
	}).join(',')

shape.style.clipPath = `polygon(${polygonCoordinates})`

See the Pen Clip-path polygon by Michelle Barker (@michellebarker) on CodePen.dark

This clips a polygon, but you’ll notice we can only see one quarter of it. The clip path is positioned in the top left corner, with the center of the polygon in the corner. This is because at some points, calculating the cartesian coordinates from the polar coordinates is going to result in negative values. The area we’re clipping is outside of the element’s bounding box.

To position the clip path centrally, we need to add half of the width and height respectively to our calculations:

const xPosition = shape.clientWidth / 2
const yPosition = shape.clientHeight / 2

const x = xPosition + Math.cos(i * angleStep) * radius
const y = yPosition + Math.sin(i * angleStep) * radius

Let’s modify our function:

const plotPoints = (radius, numberOfPoints) => {
	const xPosition = shape.clientWidth / 2
	const yPosition = shape.clientHeight / 2
	const angleStep = (Math.PI * 2) / numberOfPoints
	const points = []

	for (let i = 1; i <= numberOfPoints; i++) {
		const x = xPosition + Math.cos(i * angleStep) * radius
		const y = yPosition + Math.sin(i * angleStep) * radius

		points.push({ x, y })
	}
	
	return points
}

Our clip path is now positioned in the center.

See the Pen Clip-path polygon by Michelle Barker (@michellebarker) on CodePen.dark

Star polygons

The types of polygons we’ve plotted so far are known as convex polygons. We can also plot star polygons by modifying our code in the plotPoints() function ever so slightly. For every other point, we could change the radius value to be 50% of the original value:

/* Set every other point’s radius to be 50% */
const radiusAtPoint = i % 2 === 0 ? radius * 0.5 : radius
		
/* x & y coordinates of the current point */
const x = xPosition + Math.cos(i * angleStep) * radiusAtPoint
const y = yPosition + Math.sin(i * angleStep) * radiusAtPoint

See the Pen Clip-path star polygon by Michelle Barker (@michellebarker) on CodePen.dark

Here’s an interactive example. Try adjusting the values for the number of points and the inner radius to see the different shapes that can be made.

See the Pen Clip-path adjustable polygon by Michelle Barker (@michellebarker) on CodePen.dark

Drawing with the Canvas API

So far we’ve plotted values to use in CSS, but trigonometry has plenty of applications beyond that. For instance, we can plot points in exactly the same way to draw on a <canvas> with Javascript. In this function, we’re using the same function as before (plotPoints()) to create an array of polygon points, then we draw a line from one point to the next:

const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')

const draw = () => {
	/* Create the array of points */
	const points = plotPoints()
	
	/* Move to starting position and plot the path */
	ctx.beginPath()
	ctx.moveTo(points[0].x, points[0].y)
	
	points.forEach(({ x, y }) => {
		ctx.lineTo(x, y)
	})
	
	ctx.closePath()
	
	/* Draw the line */
	ctx.stroke()
}

See the Pen Canvas polygon (simple) by Michelle Barker (@michellebarker) on CodePen.dark

Spirals

We don’t even have to stick with polygons. With some small tweaks to our code, we can even create spiral patterns. We need to change two things here: First of all, a spiral requires multiple rotations around the point, not just one. To get the angle for each step, we can multiply pi by 10 (for example), instead of two, and divide that by the number of points. That will result in five rotations of the spiral (as 10pi divided by two is five).

const angleStep = (Math.PI * 10) / numberOfPoints

Secondly, instead of an equal radius for every point, we’ll need to increase this with every step. We can multiply it by a number of our choosing to determine how far apart the lines of our spiral are rendered:

const multiplier = 2
const radius = i * multiplier
const x = xPosition + Math.cos(i * angleStep) * radius
const y = yPosition + Math.sin(i * angleStep) * radius

Putting it all together, our adjusted function to plot the points is as follows:

const plotPoints = (numberOfPoints) => {
	const angleStep = (Math.PI * 10) / numberOfPoints
	const xPosition = canvas.width / 2
	const yPosition = canvas.height / 2

	const points = []

	for (let i = 1; i <= numberOfPoints; i++) {
		const radius = i * 2 // multiply the radius to get the spiral
		const x = xPosition + Math.cos(i * angleStep) * radius
		const y = yPosition + Math.sin(i * angleStep) * radius

		points.push({ x, y })
	}
	
	return points
}

See the Pen Canvas spiral – simple by Michelle Barker (@michellebarker) on CodePen.dark

At the moment the lines of our spiral are at equal distance from each other, but we could increase the radius exponentially to get a more pleasing spiral. By using the Math.pow() function, we can increase the radius by a larger number for each iteration. By the golden ratio, for example:

const radius = Math.pow(i, 1.618)
const x = xPosition + Math.cos(i * angleStep) * radius
const y = yPosition + Math.sin(i * angleStep) * radius

See the Pen Canvas spiral by Michelle Barker (@michellebarker) on CodePen.dark

Animation

We could also rotate the spiral, using (using requestAnimationFrame). We’ll set a rotation variable to 0, then on every frame increment or decrement it by a small amount. In this case I’m decrementing the rotation, to rotate the spiral anti-clockwise

let rotation = 0

const draw = () => {
	const { width, height } = canvas
	
	/* Create points */
	const points = plotPoints(400, rotation)
	
	/* Clear canvas and redraw */
	ctx.clearRect(0, 0, width, height)
	ctx.fillStyle = '#ffffff'
	ctx.fillRect(0, 0, width, height)
	
	/* Move to beginning position */
	ctx.beginPath()
	ctx.moveTo(points[0].x, points[0].y)
	
	/* Plot lines */
	points.forEach((point, i) => {
		ctx.lineTo(point.x, point.y)
	})
	
	/* Draw the stroke */
	ctx.strokeStyle = '#000000'
	ctx.stroke()
	
	/* Decrement the rotation */
	rotation -= 0.01
	
	window.requestAnimationFrame(draw)
}

draw()

We’ll also need to modify our plotPoints() function to take the rotation value as an argument. We’ll use this to increment the x and y position of each point on every frame:

const x = xPosition + Math.cos(i * angleStep + rotation) * radius
const y = yPosition + Math.sin(i * angleStep + rotation) * radius

This is how our plotPoints() function looks now:

const plotPoints = (numberOfPoints, rotation) => {
	/* 6 rotations of the spiral divided by number of points */
	const angleStep = (Math.PI * 12) / numberOfPoints 
	
	/* Center the spiral */
	const xPosition = canvas.width / 2
	const yPosition = canvas.height / 2

	const points = []

	for (let i = 1; i <= numberOfPoints; i++) {
		const r = Math.pow(i, 1.3)
		const x = xPosition + Math.cos(i * angleStep + rotation) * r
		const y = yPosition + Math.sin(i * angleStep + rotation) * r

		points.push({ x, y, r })
	}
	
	return points
}

See the Pen Canvas spiral by Michelle Barker (@michellebarker) on CodePen.dark

Wrapping up

I hope this series of articles has given you a few ideas for how to get creative with trigonometry and code. I’ll leave you with one more creative example to delve into, using the spiral method detailed above. Instead of plotting points from an array, I’m drawing circles at a new position on each iteration (using requestAnimationFrame).

See the Pen Canvas spiral IIII by Michelle Barker (@michellebarker) on CodePen.dark

Special thanks to George Francis and Liam Egan, whose wonderful creative work inspired me to delve deeper into this topic!

The post Trigonometry in CSS and JavaScript: Beyond Triangles appeared first on Codrops.

Trigonometry in CSS and JavaScript: Getting Creative with Trigonometric Functions

In part 1 we got an overview of trigonometry and learnt how we can use trigonometric functions in Sass. But for dynamic variables, we would be wise to move our calculations into JavaScript. Let’s take a look at an example that’s slightly more complex than clipping a simple triangle.

This article is the 2nd part in a series on “Trigonometry in CSS and JavaScript”:

  1. Introduction to Trigonometry
  2. Getting Creative with Trigonometric Functions (this article)
  3. Beyond Triangles

In the following demo we have a square-based pyramid, built with CSS 3D transforms. Using the slider, we can change the length of the sides of the pyramid, which results in changes to the overall height, and the angle of the sloping sides.

See the Pen Pyramids by Michelle Barker (@michellebarker) on CodePen.dark

To recalculate the angle at which the sides are rotated every time the input value changes, we need trigonometry. In order to do that, we can take a cross-section of the pyramid from the side, and visualize it as a triangle.

We can see that, just like our equilateral triangle in the previous article, the cross-section of our pyramid can be broken up into two right-angled triangles. (This time the shape of the cross-section is an isosceles triangle — a triangle that has two sides of equal length.)

To create the shapes for the base and sides of the pyramid, we can set the width and initial height, and use clip-path to clip the triangular shape of the sides.

.shape__base {
	--w: 10rem;
	
	width: var(--w);
	height: var(--w);
}

.shape__side {
	width: var(--side);
	height: var(--h, 20rem);
	clip-path: polygon(50% 0, 100% 100%, 0 100%);
}

I’m using custom properties here because they allow us to easily reuse identical values. I’m setting a default value for the --h custom property for the height value of the shape side, as we’ll change this value later on with JavaScript. (This is the value we’ll get from the slider.)

Going back to our cross-section diagram, we can see that our known values are the opposite side (which will be half of our --w variable) and the hypotenuse (the --h variable). What is unknown is the angle at which we need to rotate the sides so that they meet in the middle.

If we imagine the side of the pyramid originates from a starting position in the center, the angle we need to calculate is the one at the top of the triangle. We can think of it as being a bit like leaning a ladder against a wall. The angle between the ladder and the wall is the one we need to calculate.

Again, we can use custom properties in our CSS to set some transform values. Each side will have the same rotateX() value (the angle we’re going to calculate), but different rotateY() values, as they’ll be rotated around the pyramid (represented by the --ry custom property here):

.shape__side {
	transform-origin: top center;
	transform: 	
		rotateY(var(--ry, 0)) 
		rotateX(var(--angle, 15deg));	
}

.shape__side:nth-child(2) {
	--ry: 90deg;
}
	
.shape__side:nth-child(3) {
	--ry: -90deg;
}

.shape__side:nth-child(4) {
	--ry: 180deg;
}

Calculating angles

In the previous article we saw how we can calculate the length of any side of a right-angled triangle if we know the angle, but how about calculating the angle itself? For that, we need to rearrange our equations.

We know the opposite side and the hypotenuse, which indicates we need to use the Sine function. Dividing the opposite by the hypotenuse gives us sin(ϴ):

sin(angle) = o / h
Thee trigonometric equations, abbreviated to SOHCAHTOA

Therefore the angle is calculated by the inverse Sine (or Arcsine) of the opposite divided by the hypotenuse:

Math functions

We can use JavaScript math functions for this. Let’s create a function to call whenever the input changes, and update the --h (for the hypotenuse) and --angle custom properties. To get the Arcsine value we use Math.asin():

const shape = document.querySelector('.shape')
const input = document.querySelector('[data-slider]')

const setAngles = () => {
	const o = shape.clientWidth / 2
	const h = input.value
	const angle = Math.asin(o / h)
	
	shape.style.setProperty('--h', `${h}px`)
	shape.style.setProperty('--angle', `${angle}rad`)
}

input.addEventListener('input', setAngles)

Radians versus degrees

You might notice that we’re setting the --angle custom property value in radians, not degrees. Unless you’re a mathematician, there’s a good chance you usually think of angles in terms of degrees, rather than radians. A radian can be visualized as the length of the radius of a circle wrapped around the circumference. There are 2pi radians in a circle.

Diagram illustrating 2pi radians in a circle

The Math.asin() function gives us the angle in radians, and radians are perfectly legitimate units in CSS, so this will work just fine. But if you prefer to set the value in degrees, we can convert them with a simple function:

const radToDeg = (radians) => {
	return radians * (180 / Math.PI)
}

In the demo I’m also rounding the resulting value to two decimal places with toFixed():

const setAngles = () => {
	const o = shape.clientWidth / 2
	const h = input.value
	const radians = Math.asin(o / h)
	const angle = radToDeg(radians).toFixed(2)
	
	shape.style.setProperty('--h', `${h}px`)
	shape.style.setProperty('--angle', `${angle}deg`)
}

Now the angles of the sides of our pyramid will be recalculated every time we move the slider to change the length of the sides.

Get creative

Using the same method, we could even create a bunch of pyramids of random heights, by changing a single custom property:

See the Pen Pyramids by Michelle Barker (@michellebarker) on CodePen.dark

Here’s another creative example of trigonometry in action: A paper snowflake maker, where the user can drag the handles to clip out segments of a triangle to generate the snowflake pattern. The clip path coordinates were calculated using trigonometric functions.

See the Pen Snowflakes with clip-path trigonometry by Michelle Barker (@michellebarker) on CodePen.dark

In the next article we’ll see how trigonometry affords us even more creative possibilities when combined with JS, by enabling us to plot polygons and more complex shapes.

The post Trigonometry in CSS and JavaScript: Getting Creative with Trigonometric Functions appeared first on Codrops.

Trigonometry in CSS and JavaScript: Introduction to Trigonometry

Understanding trigonometry can give us super powers when it comes to creative coding. But to the uninitiated, it can seem a little intimidating. In this 3-part series of articles we’ll get an overview of trigonometry, understand how it can be useful, and delve into some creative applications in CSS and JavaScript.

  1. Introduction to Trigonometry (this article)
  2. Getting Creative with Trigonometric Functions
  3. Beyond Triangles

Trigonometry basics

If, like me, you’ve rarely used trigonometry outside of the classroom, let’s take a trip back to school and get ourselves reacquainted.

Trigonometric functions allow us to calculate unknown values of a right-angled triangle from known parameters. Imagine you’re standing on the ground, looking up at a tall tree. It would be very difficult to measure the height of the tree from the ground. But if we know the angle at which we look up at the top of the tree, and we know the distance from ourselves to the tree, we can infer the height of the tree itself.

Diagram showing a person nearby a tree. The space between them forms a right-angled triangle.

If we imagine this scene as a triangle, the known length (from us to the tree) is known as the adjacent side, the tree is the opposite side (it’s opposite the angle), and the longest side – from us to the top of the tree – is called the hypotenuse.

Diagram of a right-angled triangle, with the names of the sides.

Sine, Cosine and Tangent

There are three main functions to remember in trigonometry: Sine, Cosine and Tangent (abbreviated to sin, cos and tan). They are expressed as the following formulae:

sin(angle) = opposite / hypotenuse
cos(angle) = adjacent / hypotenuse
tan(angle) = opposite / adjacent

The angle is usually written as the Greek theta (θ) symbol.

The three equations, shown as the acronym SOHCAHTOA
The acronym SOHCAHTOA can help us remember the formulae

We can use these equations to calculate the unknown values of our triangle from the known ones. To measure the height of the tree in the example, we know the angle (θ) and the adjacent side.

Showing the adjacent side of the triangle, from the person to the tree

To calculate the opposite side we would need the tangent function. We would need to switch around the formula:

opposite = tan(angle) * adjacent
Triangle with question mark next to opposite side – the unknown side

How do we get tan(θ)? We could use a scientific calculator (type tan and then the angle), or we could use code! Sass and JavaScript both include trigonometric functions, and we’ll look at some ways to use these in this article and the following ones.

Sass functions

If we’re working with predetermined values, we could use the trigonometric functions built into Sass (the CSS preprocessor).

To include the Math module we need the following line in our Sass file:

@use "sass:math";

We can use variables to calculate the opposite side from the angle and adjacent side values.

$angle: 45deg;
$adjacent: 100%;
$opposite: math.tan($angle) * $adjacent;

The tan function in Sass can use radians or degrees — if using degrees, the units must be specified. Without units, radians will be used by default (more on these later).

In the following demo we’re using these in the clip-path property to determine the coordinates of the polygon points, similar to calculating the height of a tree.

See the Pen Using Sass trigonometry for clip-path values by Michelle Barker (@michellebarker) on CodePen.dark

We need to subtract the $opposite variable from the height of the element in order to get the y coordinate — as clip-path coordinates are plotted along the y axis increasing from top to bottom.

.element {
	clip-path: polygon(0 100%, $adjacent (100% - $opposite), $adjacent 100%);
}
Showing the clipped triangle within the square box of the element.

Clipping an equilateral triangle

A right-angled triangle is the simplest use of trigonometry. But we can work out the coordinates of more complex shapes by splitting them up into right-angled triangles.

An equilateral triangle is a triangle with three sides of the same length. Perhaps you remember from school that the angles in a triangle add up to 180º? That means each angle in an equilateral triangle is 60º.

An equilateral triangle. Each angle is 60 degrees

If we draw a line down the middle of an equilateral triangle, we split it into (you guessed it) two right-angled triangles. So, for a triangle with sides of a given length, we know the angle (60º), the length of the hypotenuse, and the length of the adjacent side (half the length of the hypotenuse).

An equilateral triangle split into two right-angled triangles.

What we don’t know is the height of the triangle — once again, the opposite side of the right-angled triangle. To plot the clip-path coordinates, this is what we need to work out. This time, as we know the angle and the length of the hypotenuse, we can use the sine function:

$hypotenuse: 60%; // side length
$angle: 60deg;
$opposite: math.sin($angle) * $hypotenuse;

(It would also be possible for us to use the tangent function instead, as we know that the length of the adjacent side is half of the hypotenuse.) Then we can use those values for our clip-path polygon points:

.element {
	clip-path: polygon(
		0 $opposite,
		($hypotenuse / 2) 0,
		$hypotenuse $opposite
	);
}

See the Pen Clip-path simple equilateral triangles with Sass by Michelle Barker (@michellebarker) on CodePen.dark

As you can see in the demo, the element is clipped from the top left corner. This might not be completely satisfactory: it’s more likely we’d want to clip from the center, especially if we’re clipping an image. We can adjust our clip-path coordinates accordingly. To make this more readable, we can assign some additional variables for the adjacent side length (half the hypotenuse), and the start and end position of the triangle:

$hypotenuse: 60%; //side length
$angle: 60deg;
$opposite: math.sin($angle) * $hypotenuse;
$adjacent: $hypotenuse / 2;
$startPosX: (50% - $adjacent);
$startPosY: (50% - $opposite / 2);
$endPosX: (50% + $adjacent);
$endPosY: (50% + $opposite / 2);

.element {
	clip-path: polygon(
		$startPosX $endPosY,
		50% $startPosY,
		$endPosX $endPosY
	);
}
Triangle centrally positioned

Creating a mixin for reuse

This is quite a bit of complex code to write for a single triangle. Let’s create a Sass mixin, allowing us to clip a triangle of any size on any element we like. As clip-path still needs a prefix in some browsers, our mixin covers that too:

@mixin triangle($sideLength) {
	$hypotenuse: $sideLength;
	
	$angle: 60deg;
	$opposite: math.sin($angle) * $hypotenuse;
	$adjacent: $hypotenuse / 2;
	$startPosX: (50% - $adjacent);
	$startPosY: (50% - $opposite / 2);
	$endPosX: (50% + $adjacent);
	$endPosY: (50% + $opposite / 2);
	
	$clip: polygon(
				$startPosX $endPosY,
				50% $startPosY,
				$endPosX $endPosY
			);
	
	-webkit-clip-path: $clip;
	clip-path: $clip;
}

To clip a centred equilateral triangle from any element, we can simply include the mixin, passing in the length of the triangle’s sides:

.triangle {
	@include triangle(60%);
}

See the Pen Clip-path equilateral triangles with Sass trigonometric functions by Michelle Barker (@michellebarker) on CodePen.dark

Limitations of Sass functions

Our use of Sass functions has some limitations:

  1. It assumes the $sideLength variable is known at compile time, and doesn’t allow for dynamic values.
  2. Sass doesn’t handle mixing units all that well for our needs. In the last demo, if you switch out the percentage-based side length to a fixed length (such as rems or pixels), the code breaks.

The latter is because our calculations for the $startPos and $endPos variables (to position the clip-path centrally) depend on subtracting the side length from a percentage. Unlike in regular CSS (using calc()), Sass doesn’t allow for that. In the final demo, I’ve adjusted the mixin so that it works for any valid length unit, by passing in the size of the clipped element as a parameter. We’d just need to ensure that the values for the two parameters passed in have identical units.

See the Pen Clip-path equilateral triangles with Sass trigonometric functions by Michelle Barker (@michellebarker) on CodePen.dark

CSS trigonometric functions

CSS has a proposal for trigonometric functions as part of the CSS Values and Units Module Level 4 (currently in working draft). These could be extremely useful, especially when used alongside custom properties. Here’s how we could rewrite our CSS to use native CSS trigonometric functions. Changing the size of the clip path is as simple as updating a single custom property:

.triangle {
	--hypotenuse: 8rem;
	--opposite: calc(sin(60deg) * var(--hypotenuse));
	--adjacent: calc(var(--hypotenuse) / 2);
	--startPosX: calc(var(--size) / 2 - var(--adjacent));
	--startPosY: calc(var(--size) / 2 - var(--opposite) / 2);
	--endPosX: calc(var(--size) / 2 + var(--adjacent));
	--endPosY: calc(var(--size) / 2 + var(--opposite) / 2);
	
	--clip: polygon(
				var(--startPosX) var(--endPosX),
				50% var(--startPosY),
				var(--endPosX) var(--endPosY)
			);
			
	-webkit-clip-path: var(--clip);
	clip-path: var(--clip);
}

.triangle:nth-child(2) {
	--hypotenuse: 3rem;
}

.triangle:nth-child(2) {
	--hypotenuse: 50%;
}

Dynamic variables

Custom properties can be dynamic too. We can change them with JS and the values dependant on them will be automatically recalculated.

triangle.style.setProperty('--hypotenuse', '5rem')

CSS trigonometric functions have a lot of potential when they finally land, but sadly they’re not yet supported in any browsers. To use trigonometry with dynamic variables right now, we need JavaScript.

We’ll take a look at some of the possibilities in the next article.

The post Trigonometry in CSS and JavaScript: Introduction to Trigonometry appeared first on Codrops.

Dynamic CSS Masks with Custom Properties and GSAP

I recently redesigned my personal website to include a fun effect in the hero area, where the user’s cursor movement reveals alternative styling on the title and background, reminiscent of a spotlight. In this article we’ll walk through how the effect was created, using masks, CSS custom properties and much more.

Duplicating the content

We start with HTML to create two identical hero sections, with the title repeated.

<div class="wrapper">
 <div class="hero">
  <h1 class="hero__heading">Welcome to my website</h1>
 </div>

 <div class="hero hero--secondary" aria-hidden="true">
  <p class="hero__heading">Welcome to my website</p>
 </div>
</div>

Duplicating content isn’t a great experience for someone accessing the website using a screenreader. In order to prevent screenreaders announcing it twice, we can use aria-hidden="true" on the second component. The second component is absolute-positioned with CSS to completely cover the first.

Hero section with bright gradient positioned over the dark section
The two sections with the same content are layered one on top of the other

Using pointer-events: none on the second component ensures that the text of the first will be selectable by users.

Styling

Now we can add some CSS to style the two components. I deliberately chose a bright, rich gradient for the “revealed” background, in contrast to the dark monochrome of the initial view.

Somewhat counterintuitively, the component with the bright background is actually the one that will cover the other. In a moment we’ll add the mask, so that parts of it will be hidden — which is what gives the impression of it being underneath.

Text effects

There are a couple of different text effects at play in this component. The first applies to the bright text on the dark background. This uses -webkit-text-stroke, a non-standard CSS property that is nonetheless supported in all modern browsers. It allows us to outline our text, and works great with bold, chunky fonts like the one we’re using here. It requires a prefix in all browsers, and can be used as shorthand for -webkit-stroke-width and -webkit-stroke-color.

In order to get the “glow” effect, we can set the text color to a transparent value and use the CSS drop-shadow filter with the same color value. (We’re using a CSS custom property for the color in this example):

.heading {
  -webkit-text-stroke: 2px var(--primary);
  color: transparent;
  filter: drop-shadow(0 0 .35rem var(--primary));
}

See the Pen Outlined text by Michelle Barker (@michellebarker) on CodePen.dark

The text on the colored panel has a different effect applied. The intention was, for it to feel a little like an x-ray revealing the skeleton underneath. The text fill has a dotted pattern, which is created using a repeated radial gradient. To get this effect on the text, we in fact apply it to the background of the element, and use background-clip: text, which also requires a prefix in most browsers (at the time of writing). Again, we need to set the text color to a transparent value in order to see the result of the background-clip property:

.hero--secondary .heading {
  background: radial-gradient(circle at center, white .11rem, transparent 0);
  background-size: .4rem .4rem;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

See the Pen Dotted text by Michelle Barker (@michellebarker) on CodePen.dark

Creating the spotlight

We have two choices when it comes to creating the spotlight effect with CSS: clip-path and mask-image. These can produce very similar effects, but with some important differences.

Clipping

We can think of clipping a shape with clip-path as a bit like cutting it out with scissors. This is ideal for shapes with clean lines. In this case, we could create a circle shape for our spotlight, using the circle() function:

.hero--secondary {
  --clip: circle(20% at 70%);
  -webkit-clip-path: var(--clip);
  clip-path: var(--clip);
}

(clip-path still needs to be prefixed in Safari, so I like to use a custom property for this.)

clip-path can also take an ellipse, polygon, SVG path or a URL with an SVG path ID.

See the Pen Hero with clip-path by Michelle Barker (@michellebarker) on CodePen.dark

Masking

Unlike clip-path the mask-image property is not limited to shapes with clean lines. We can use a PNGs, SVGs or even GIFs to create a mask. We can even use gradients: the blacker parts of the image (or gradient) act as the mask whereas the element will be hidden by the transparent parts.

We can use a radial gradient to create a mask very similar to the clip-path circle:

.hero--secondary {
  --mask: radial-gradient(circle at 70%, black 25%, transparent 0);
  -webkit-clip-path: var(--mask);
  clip-path: var(--mask);
}

Another advantage is that there are additional mask properties than correspond to CSS background properties — so we can control the size and position of the mask, and whether or not it repeats in much the same way, with mask-size, mask-position and mask-repeat respectively.

See the Pen Hero with mask by Michelle Barker (@michellebarker) on CodePen.dark

There’s much more we could delve into with clipping and masking, but let’s leave that for another day! I chose to use a mask instead of a clip-path for this project — hopefully the reason will become clear a little later on.

Tracking the cursor

Now we have our mask, it’s a matter of tracking the position of the user’s cursor, for which we’ll need some Javascript. First we can set custom properties for the center co-ordinates of our gradient mask. We can use default values, to give the mask an initial position before the JS is executed. This will also ensure that non-mouse users see a static mask, rather than none at all.

.hero--secondary {
  --mask: radial-gradient(circle at var(--x, 70%) var(--y, 50%), black 25%, transparent 0);
}

In our JS, we can listen for the mousemove event, then update the custom properties for the x and y percentage position of the circle in accordance with the cursor position:

const hero = document.querySelector('[data-hero]')

window.addEventListener('mousemove', (e) => {
  const { clientX, clientY } = e
  const x = Math.round((clientX / window.innerWidth) * 100)
  const y = Math.round((clientY / window.innerHeight) * 100)
	
  hero.style.setProperty('--x', `${x}%`)
  hero.style.setProperty('--y', `${y}%`)
})

See the Pen Hero with cursor tracking by Michelle Barker (@michellebarker) on CodePen.dark

(For better performance, we might want to throttle or debounce that function, or use requestAnimationFrame, to prevent it repeating too frequently. If you’re not sure which to use, this article has you covered.)

Adding animation

At the moment there is no easing on the movement of the spotlight — it immediately updates its position when the mouse it moved, so feels a bit rigid. We could remedy that with a bit of animation.

If we were using clip-path we could animate the path position with a transition:

.hero--secondary {
  --clip: circle(25% at 70%);
  -webkit-clip-path: var(--clip);
  clip-path: var(--clip);
  transition: clip-path 300ms 20ms;
}

Animating a mask requires a different route.

Animating with CSS Houdini

In CSS we can transition or animate custom property values using Houdini – a set of low-level APIs that give developers access to the browser’s rendering engine. The upshot is we can animate properties (or, more accurately, values within properties, in this case) that aren’t traditionally animatable.

We first need to register the property, specifying the syntax, whether or not it inherits, and an initial value. The initial-value property is crucial, otherwise it will have no effect.

@property --x {
  syntax: '';
  inherits: true;
  initial-value: 70%;
}

Then we can transition or animate the custom property just like any regular animatable CSS property. For our spotlight, we can transition the --x and --y values, with a slight delay, to make them feel more natural:

.hero--secondary {
  transition: --x 300ms 20ms ease-out, --y 300ms 20ms ease-out;
}

See the Pen Hero with cursor tracking (with Houdini animation) by Michelle Barker (@michellebarker) on CodePen.dark

Unfortunately, @property is only supported in Chromium browsers at the time of writing. If we want an improved animation in all browsers, we could instead reach for a JS library.

Animating with GSAP

In CSS we can transition or animate custom property values using Houdini –I love using the Greensock(GSAP) JS animation library. It has an intuitive API, and contains plenty of easing options, all of which makes animating UI elements easy and fun! As I was already using it for other parts of the project, it was a simple decision to use it here to bring some life to the spotlight. Instead of using setProperty we can let GSAP take care of setting our custom properties, and configure the easing using the built in options:

import gsap from 'gsap'

const hero = document.querySelector('[data-hero]')

window.addEventListener('mousemove', (e) => {
  const { clientX, clientY } = e
  const x = Math.round((clientX / window.innerWidth) * 100)
  const y = Math.round((clientY / window.innerHeight) * 100)
	
  gsap.to(hero, {
    '--x': `${x}%`,
    '--y': `${y}%`,
    duration: 0.3,
    ease: 'sine.out'
  })
})

See the Pen Hero with cursor tracking (GSAP) by Michelle Barker (@michellebarker) on CodePen.dark

Animating the mask with a timeline

The mask on my website’s hero section is slightly more elaborate than a simple spotlight. We start with a single circle, then suddenly another circle “pops” out of the first, surrounding it. To get an effect like this, we can once again turn to custom properties, and animate them on a GSAP timeline.

Our radial gradient mask becomes a little more complex: We’re creating a gradient of two concentric circles, but setting the initial values of the gradient stops to 0% (via the default values in our custom properties), so that their size can be animated with JS:

.hero {
  --mask: radial-gradient(
    circle at var(--x, 50%) var(--y, 50%),
    black var(--maskSize1, 0%), 
    transparent 0, 
    transparent var(--maskSize2, 0%),
    black var(--maskSize2, 0%), 
    black var(--maskSize3, 0%), 
    transparent 0);
}

Our mask will be invisible at this point, as the circle created with the gradient has a size of 0%. Now we can create a timeline with GSAP, so the central spot will spring to life, followed by the second circle. We’re also adding a delay of one second before the timeline starts to play.

const tl = gsap.timeline({ delay: 1 })

tl
  .to(hero, {
    '--maskSize1': '20%',
    duration: 0.5,
    ease: 'back.out(2)'
  })
  .to(hero, {
    '--maskSize2': '28%',
     '--maskSize3': 'calc(28% + 0.1rem)',
    duration: 0.5,
    delay: 0.5,
    ease: 'back.out(2)'
})

See the Pen Hero with cursor tracking (GSAP) by Michelle Barker (@michellebarker) on CodePen.dark

Using a timeline, our animations will execute one after the other. GSAP offers plenty of options for orchestrating the timing of animations with timelines, and I urge you to explore the documentation to get a taste of the possibilities. You won’t be disappointed!

Smoothing the gradient

For some screen resolutions, a gradient with hard color stops can result in jagged edges. To avoid this we can add some additional color stops with fractional percentage values:

.hero {
  --mask: radial-gradient(
    circle at var(--x, 50%) var(--y, 50%),
    black var(--maskSize1, 0%) 0,
    rgba(0, 0, 0, 0.1) calc(var(--maskSize1, 0%) + 0.1%),
    transparent 0,
    transparent var(--maskSize2, 0%),
    rgba(0, 0, 0, 0.1) calc(var(--maskSize2, 0%) + 0.1%),
    black var(--maskSize2, 0%),
    rgba(0, 0, 0, 0.1) calc(var(--maskSize3, 0%) - 0.1%),
    black var(--maskSize3, 0%),
    rgba(0, 0, 0, 0.1) calc(var(--maskSize3, 0%) + 0.1%),
    transparent 0
  );
}

This optional step results in a smoother-edged gradient. You can read more about this approach in this article by Mandy Michael.

A note on default values

While testing this approach, I initially used a default value of 0 for the custom properties. When creating the smoother gradient, it turned out that the browser didn’t compute those zero values with calc, so the mask wouldn’t be applied at all until the values were updated with JS. For this reason, I’m setting the defaults as 0% instead, which works just fine.

Creating the menu animation

There’s one more finishing touch to the hero section, which is a bit of visual trickery: When the user clicks on the menu button, the spotlight expands to reveal the full-screen menu, seemingly underneath it. To create this effect, we need to give the menu an identical background to the one on our masked element.

:root {
  --gradientBg: linear-gradient(45deg, turquoise, darkorchid, deeppink, orange);
}

.hero--secondary {
  background: var(--gradientBg);
}

.menu {
  background: var(--gradientBg);
}

The menu is absolute-positioned, the same as the masked hero element, so that it completely overlays the hero section.

Then we can use clip-path to clip the element to a circle 0% wide. The clip path is positioned to align with the menu button, at the top right of the viewport. We also need to add a transition, for when the menu is opened.

.menu {
  background: var(--gradientBg);
  clip-path: circle(0% at calc(100% - 2rem) 2rem);
  transition: clip-path 500ms;
}

When a user clicks the menu button, we’ll use JS to apply a class of .is-open to the menu.

const menuButton = document.querySelector('[data-btn="menu"]')
const menu = document.querySelector('[data-menu]')

menuButton.addEventListener('click', () => {
  menu.classList.toggle('is-open')
})

(In a real project there’s much more we would need to do to make our menu fully accessible, but that’s beyond the scope of this article.)

Then we need to add a little more CSS to expand our clip-path so that it reveals the menu in its entirety:

.menu.is-open {
  clip-path: circle(200% at calc(100% - 2rem) 2rem);
}

See the Pen Hero with cursor tracking and menu by Michelle Barker (@michellebarker) on CodePen.dark

Text animation

In the final demo, we’re also implementing a staggered animation on the heading, before animating the spotlight into view. This uses Splitting.js to split the text into <span> elements. As it assigns each character a custom property, it’s great for CSS animations. The GSAP timeline however, is a more convenient way to implement the staggered effect in this case, as it means we can let the timeline handle when to start the next animation after the text finishes animating. We’ll add that to the beginning of our timeline:

// Set initial text styles (before animation)
gsap.set(".hero--primary .char", {
  opacity: 0,
  y: 25,
});

/* Timeline */
const tl = gsap.timeline({ delay: 1 });

tl
  .to(".hero--primary .char", {
    opacity: 1,
    y: 0,
    duration: 0.75,
    stagger: 0.1,
  })
  .to(hero, {
    "--maskSize1": "20%",
    duration: 0.5,
    ease: "back.out(2)",
  })
  .to(hero, {
    "--maskSize2": "28%",
    "--maskSize3": "calc(28% + 0.1rem)",
    duration: 0.5,
    delay: 0.3,
    ease: "back.out(2)",
  });

I hope this inspires you to play around with CSS masks and the fun effects that can be created!

The full demo

The post Dynamic CSS Masks with Custom Properties and GSAP appeared first on Codrops.

Color Theming with CSS Custom Properties and Tailwind

Custom properties not only enable us to make our code more efficient, but allow us to work some real magic with CSS too. One area where they have huge potential is theming. At Atomic Smash we use Tailwind CSS, a utility class framework, for writing our styles. In this article, we’ll look at how custom properties can be used for theming, and how we can integrate them with Tailwind to maximize the reusability of our code. We won’t cover getting up and running with Tailwind — check out the official documentation for that — but even if you’re new to it you might find some of these tips useful.

Theming overview

Let’s say we have a “Call To Action” (CTA) component with a heading, body copy, and button.

A box with a light red heading that reads join our mailing list above a dark red body that reads be the first to hear about our new offerings right before a red signup button.

Writing regular (non-Tailwind) CSS for this color scheme would look something like this:

.cta {
  background-color: #742a2a; // dark red
  color: #ffffff; //white
}
    
.cta__heading {
  background-color: #e53e3e; // medium red
  color: #742a2a;
}


.cta__button {
  background-color: #e53e3e;
}

Using Tailwind, we would apply these colors as utility classes in our HTML:

<div class="bg-red-900 text-white">
  <h3 class="bg-red-600 text-red-900">Join our mailing list</h3>
  <div>
    <p>Be the first to hear about our new offerings</p>
    <button class="bg-red-600" type="button">Sign up</button>
  </div>
</div>

I’ve deliberately left out classes relating to anything other than the basic color scheme, but you can see the example in its entirety in this demo:

Now, if we wanted to apply a different color scheme to our component, we would need to override the color values of our original component. Without Tailwind, a common way to do that would be to append a theme class to the component itself, and redefine the color values lower down in the cascade. So for a component with a modifier class of .cta--blue (using the BEM convention) we’ll apply the CSS values for a blue color scheme:

.cta--blue {
  background-color: #2a4365; // dark blue
}


.cta--blue .cta__heading {
  background-color: #3182ce; // medium blue
  color: #2a4365;
}


.cta--blue .cta__button {
  background-color: #3182ce;
}
A box with a light blue heading that reads join our mailing list above a dark bluebody that reads be the first to hear about our new offerings right before a blue signup button.

If we’re using Sass or another preprocessor, it’s likely we’ll make life easier for ourselves by using variables for those color names, and we might nest the .cta__heading and .cta__body selectors. It doesn’t exactly make our code more concise, but it does make it more manageable by having a single place to update those values.

Now, suppose we have 10 different color schemes, as was my experience on a recent project. Our code starts to get longer, as we’re basically duplicating the above example 10 times in order to change those color values. Now imagine every component in our design system needs 10 color schemes, and many of those components are far more complex than our simple CTA. Maybe our themes need different fonts too. Suddenly we have a lot of CSS to write.

Theming with Tailwind

If we’re using Tailwind, on the other hand, we’d need to change multiple classes in the HTML itself. Even if we’re using a JavaScript framework, like React or Vue, this is not exactly a trivial task. In order to ensure unused styles are removed in a production build, Tailwind discourages the use of string concatenation for class names (at the time of writing). So building our themes means potentially piling a lot of logic into our components.

Theming with Custom Properties

By using custom properties for our color themes, we can drastically reduce the amount of code we need to write, and alleviate the maintenance burden. Let’s first take a look at how we can do this in regular CSS.

We define our custom properties as variables on the :root selector, making them global variables. (The body selector would serve us just as well.) Then we can use those variables in a selector, in place of our color property values:

:root {
  --primary: #742a2a; // dark red;
  --secondary: #e53e3e; // medium red
}


.cta {
  background-color: var(--primary);
  color: white;
}


.cta__heading {
  background-color: var(--secondary);
  color: var(--primary);
}


.cta__button {
  background-color: var(--secondary);
}

This is where the real magic happens: now the code for creating each of our themes becomes a case of only updating those custom property values. The new values will be inherited wherever we apply our theme class:

.th-blue {
  --primary: #2a4365; // dark blue
  --secondary: #3182ce; // medium blue
}

If we want a blue color scheme, we can apply that .th-blue class to the component, or even use it on the <body> tag to apply to apply a page-wide theme, which can be overridden on individual components as desired. Using a utility class potentially saves us writing even more code compared to a component-specific class (such as .cta--blue in the original code), as it could be applied anywhere in our codebase.

Handling older browsers

Like many agencies, plenty of our clients at Atomic Smash still require us to support Internet Explorer 11. While I’m okay with a progressive enhancement approach in most cases (by providing simpler fallback layouts for browsers that don’t support CSS Grid, for instance), I find theming is one area that often doesn’t allow for easy compromise. Clients want their brand colors and fonts seen, even on older browsers. Providing fallbacks using feature queries would entail a lot of extra work that would negate the benefits of using custom properties in the first place. To overcome this, we need a polyfill.

There are a couple of options for polyfilling custom properties in IE 11.

postcss-custom-properties

The first is using a PostCSS plugin called postcss-custom-properties. If you’re already using PostCSS in your workflow, this is fairly simple to add. It works by processing your CSS and outputting the result of the variable as the property value. So if you have the following CSS:

:root {
  --color: red;
}


h1 {
  color: var(--color);
}

The processed result will be:

h1 {
  color: red;
  color: var(--color);
}

Browsers that don’t support custom properties will ignore the second rule and fall back to the regular property value. There is also an option to remove the rules with the custom properties in the output, so the file size will be smaller. This means that no browsers will get the custom property — which is an issue if you’re updating variables dynamically — but you’ll be able to use them for static values in your code with no ill effects.

Unfortunately this polyfill has some limitations:

  1. You need to specify the file (or files) in your config where you’re defining the custom properties.
  2. Custom properties can only be defined on the :root selector.

The first limitation is relatively trivial, but the second unfortunately renders this polyfill entirely useless for our theming use case. It means we can’t redefine variables on a selector to create our themes.

ie11CustomProperties

This polyfill option involves serving a client-side script, rather than preprocessing the CSS. We can add the following script to our head to ensure the polyfill will only be loaded in IE 11:

<script>window.MSInputMethodContext && document.documentMode && document.write('<script src="https://cdn.jsdelivr.net/gh/nuxodin/ie11CustomProperties@4.1.0/ie11CustomProperties.min.js"><\/script>');</script>

This permits us to enjoy the full benefits of custom properties as in the examples here, so it’s the solution I decided to go with. It has a limitation where custom properties set in style attributes aren’t polyfilled. But I’ve tested it for the theming example above and it works just fine.

But what does this have to do with Tailwind? 

As we’ve already seen, utility classes — single-purpose classes that can be applied anywhere in our HTML — can make our code more reusable. That’s the main selling point of Tailwind and other utility class frameworks — the size of the CSS file you ship should end up smaller as a result. Tailwind makes multiple color classes available: .bg-red-medium would give us a red background-color property value, .text-red-medium for color and so on for border, box-shadow, or any place you can think of that you might need a color value. 

Colors can be defined in a config file:

module.exports = {
  theme: {
    colors: {
      red: {
        medium: '#e53e3e',
        dark: '#742a2a'
      },
      blue: {
        medium: '#3182ce',
        dark: '#2a4365'
      }
    }
  }
}

If we want to use custom property values for our Tailwind classes, we can specify them in the config:

module.exports = {
  theme: {
    colors: {
      'th-primary': 'var(--primary)',
      'th-secondary': 'var(--secondary)'
    }
  }
}

I’m prefixing my colors and theme-related class names with th- so that it’s obvious they’re specifically related to theming, but feel free to use whatever convention suits you.

Now those classes will be available to us through Tailwind. Using .bg-th-primary gives us the equivalent of writing:

.some-element {
  background-color: var(--primary);
}

In our CSS we can define our custom properties for our themes as before:

:root {
  --primary: #742a2a;
  --secondary: #742a2a;
}


.th-blue {
  --primary: #2a4365;
  --secondary: #3182ce;
}

Let’s apply those classes to our HTML. The first example gives us a component with our default theme (the variables defined on the :root). The second has our blue theme. The only difference is the addition of the .th-blue class on the component. (Once again, I’ve omitted the classes unrelated to the theme, for brevity and clarity.)

<!--Component with default (red) theme-->
<div class="bg-th-primary">
  <h3 class="bg-th-secondary text-th-primary">Join our mailing list</h3>
  <div>
    <p>Be the first to hear about our new offerings</p>
    <button class="bg-th-secondary" type="button">Sign up</button>
  </div>
</div>


<!--Component with blue theme-->
<div class="th-blue bg-th-primary">
  <h3 class="bg-th-secondary text-th-primary">Join our mailing list</h3>
  <div>
    <p>Be the first to hear about our new offerings</p>
    <button class="bg-th-secondary" type="button">Sign up</button>
  </div>
</div>

Using the config as a style guide

Tailwind encourages you to define all variables in the config, and personally I agree that it’s a better approach. It means that the config file can be a single source of truth rather than (potentially) ending up with multiple places to define your colors and other theme values. Luckily, we can also use values from the Tailwind config file for our custom properties. We’ll need to first define all of our colors in the config (assuming we’re not using the default color palette included with Tailwind):

module.exports = {
  theme: {
    colors: {
      red: {
        medium: '#e53e3e',
        dark: '#742a2a'
      },
      blue: {
        medium: '#3182ce',
        dark: '#2a4365'
      },
      'th-primary': 'var(--primary)',
      'th-secondary': 'var(--secondary)'
    }
  }
}

Then we can access the theme object in the CSS:

:root {
  --th-primary: theme('colors.red.dark');
  --th-secondary: theme('colors.red.medium');
}


.th-blue {
  --th-primary: theme('colors.blue.dark');
  --th-secondary: theme('colors.blue.medium');
}

Wrapping up

I’m really excited about the benefits of being able to use custom properties without having to worry about browser support, even more so to be able to integrate them smoothly with our existing workflow. It’s hard to overstate the amount of time they will save us for theming. I hope that even if you’re not a Tailwind user, this article might encourage you to give custom properties a go for this use case.


The post Color Theming with CSS Custom Properties and Tailwind appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.