Luigi Cavalieri - Authoring Open Source Code

How to Build a Retractable Sidebar with React and CSS

In this post I go into the details of developing a retractable sidebar which opens on a blurred background area, by helping you make yours the reasoning behind the development process.

How to Build a Retractable Sidebar with React and CSS

In developing a RetractableSidebar component with React, there is something more important than the code on its own, even more important than the CSS trick we'll use to actualise the animation. This something is the understanding of how to carefully time the events at play. So, my goal with this post is to help you make yours the reasoning behind the development process, more than anything else, because once mastered that, the customisation of the RetractableSidebar component will flow naturally, by giving you an enjoyable development time.

Overview of the Example App

The example app we are going to use in this guide is very minimalistic: just a button that, when clicked, makes the sidebar to slide into view from the right. I think, however, that a screenshot can talk much better:

Retractable sidebar built with React and CSS.
Retractable sidebar built with React and CSS.

The user can hide the sidebar — something which is still done with a sliding animation — by clicking on either the close button or the blurred area outside the sidebar.

The key point in achieving such animation by keeping the React code minimal, is to leverage the power of CSS. In fact, the only task we will delegate to React is to toggle on and off a couple of CSS classes. The animation per se will be carried out by some plain CSS alone.

Just as a reference, the src folder of our example app will contain the following files:

1+ src
2    - App.tsx
3    - index.css
4    - main.tsx
5    + RetractableSidebar
6        - RetractableSidebar.module.scss
7        - RetractableSidebar.tsx
8    + styles
9        - _variables.scss
10        - variables-export.module.scss

Let's Start with the 'App' Component

The App component is where it's located the state variable that keeps track of the "opened state" of the RetractableSidebar component, as you can read from the code below:

1/* App.tsx */
2
3import { useState } from "react";
4import RetractableSidebar from "./RetractableSidebar/RetractableSidebar";
5
6
7export default function App() {
8  const [ isSidebarOpened, setIsSidebarOpened ] = useState( false );
9  
10  return (
11    <>
12      <button onClick={() => setIsSidebarOpened( true )}>
13        Open Sidebar
14      </button>
15      <RetractableSidebar
16        opened={isSidebarOpened}
17        onClickClose={() => setIsSidebarOpened( false )}
18      />
19    </>
20  );
21}

The reason why the "opened state" is controlled from the outside of the RetractableSidebar component is that the change of such state originates from two distinct places of the app: from the inside of RetractableSidebar, when we click on the close button, and from the outside of RetractableSidebar, when we click on the open button, instead.

The CSS Trick Fuelling the Sliding Animation

In a nutshell, the sliding animation comes from transitioning the margin-right property of our sidebar.

To better understand what I'm speaking about, let's have a look at the following picture, which highlights the key parts of the user interface:

The key parts of the user interface.
The key parts of the user interface.

The negative margin-right is the very trick I was referring to in the introduction. By playing with its value, in fact, we are able to make the .sidebar to slide in and out of view. In general, the margin rule in CSS is one of those animatable rules which gives better cross-browser compatibility in the context of CSS animations, hence the reason for choosing it to achieve the sliding animation.

Styling the 'RetractableSidebar' Component

From the picture just seen, I guess you already have an idea about the JSX returned by the RetractableSidebar component, don't you? Well, our starting point is going to be the following code:

1/* RetractableSidebar/RetractableSidebar.tsx */
2
3import styles from "./RetractableSidebar.module.scss";
4
5
6export default function RetractableSidebar({
7  opened,
8  onClickClose
9}: {
10  opened: boolean;
11  onClickClose: () => void;
12}) {
13  // Here will go the magic.
14
15  return (
16    <div className={styles.wrapper}>
17      <aside className={styles.sidebar}>
18        <button>
19          Close Sidebar
20        </button>
21      </aside>
22    </div>
23  );
24}

Nothing unusual, an <aside>, our sidebar, contained by a wrapper, the blurred area.

Here, the choice of using SCSS modules is a personal preference. You could as well rewrite the SCSS part of this guide by using a CSS-in-JS solution like that offered by the styled-components package. The result would be the same. If you are curious to know my reasons on favouring SCSS modules, I'll tell you that I like them better for three main points:

  • they allow us to improve the organization of our code
  • they help us to keep at bay the size of the JS file resulting from the build step
  • the CSS file resulting from the build step can be cached by the browser

Going back to the styling of our component, the following is the CSS used by RetractableSidebar — I added a few inline comments to the main rules of the cascade:

1/* RetractableSidebar/RetractableSidebar.module.scss */
2
3@import "../styles/variables";
4
5
6.wrapper {
7  background-color: #00000030;
8  backdrop-filter: blur(5px);
9  overflow: hidden;
10  
11  // Initially, the .wrapper is hidden.
12  display: none;
13
14  // Moves the .sidebar to the right side.
15  justify-content: flex-end;
16
17  // Always on top of everything.
18  z-index: 1000;
19  
20  // The following rules ensure that, 
21  // whenever visible, .wrapper covers 
22  // the whole viewport.
23  left: 0;
24  height: 100vh;
25  position: fixed;
26  width: 100vw;
27  top: 0;
28
29  &.visible {
30    display: flex;
31  }
32}
33
34.sidebar {
35  align-items: center;
36  background-color:indianred;
37  display: flex;
38  justify-content: center;
39  width: $sidebar-width;
40
41  // The animation is achieved by
42  // playing with the value of margin-right
43  transition: margin-right #{$animation-duration-ms}ms ease;
44
45  // Initially the .sidebar is hidden.
46  margin-right: -$sidebar-width;
47  
48  &.opened {
49    margin-right: 0;
50  }
51}

The two utility classes you read in the code, .visible and .opened, are the very classes we will use later on to control the sliding animation.

Whilst, the file imported in the SCSS above is a stand-alone file of global variables. Most of the time, having a habit of saving frequently-used values into a separate file of global variables can go a long way. Often actually, the TypeScript code too, needs access to some of these values, so if they are saved to their own file, its easier to import them using a SCSS module — as we'll see.

To leave nothing out, our file of _variables.scss has the following content:

1/* styles/_variables.scss */
2
3$sidebar-width: 300px;
4$animation-duration-ms: 300; // milliseconds

Making the Sidebar to Slide into View

In the code below we use the opened prop to conditionally apply the .visible and .opened classes to our markup:

1/* RetractableSidebar/RetractableSidebar.tsx */
2
3import classNames from "classnames";
4import styles from "./RetractableSidebar.module.scss";
5
6
7export default function RetractableSidebar({
8  opened,
9  onClickClose
10}: {
11  opened: boolean;
12  onClickClose: () => void;
13}) {
14  // Here will go the magic.
15
16  return (
17    <div
18      onClick={onClickClose}
19      className={classNames(
20        styles.wrapper,
21        {
22          [styles.visible]: opened
23        }
24      )}
25    >
26      <aside
27        className={classNames(
28          styles.sidebar,
29          {
30            [styles.opened]: opened  
31          }
32        )}
33      >
34        <button onClick={onClickClose}>
35          Close Sidebar
36        </button>
37      </aside>
38    </div>
39  );
40}

Just an aside, the classnames tool used in the script is a third-party package you may want to eventually install via npm. If you never heard of it, don't worry, it's an utility as essential as easy to use. In the words of the developers who created it...

[classnames is] a simple JavaScript utility for conditionally joining classNames together.

If you try to use the app right now, you'll find out that there is a problem: when we click on the open button, the sidebar opens abruptly. The sliding animation doesn't work.

The reason for this behaviour is that, initially, the wrapper is hidden with a display: none rule. Generally speaking, when we turn off the display of an element, the webpage's markup is rendered as though the element did not exist. So, when we add the .visible class to the wrapper, it's as though we are instantly adding the wrapper to the webpage, by leaving no time for the animation to run.

Allowing the Animation to Run

The solution is to add the .opened class to the sidebar only after the wrapper is visible on the screen. In other words, the .visible and .opened classes must be added to their target elements during two separate renderings of the RetractableSidebar component. This can be achieved by using a state internal to the component and updating it in the callback of a useEffect() hook, like so:

1/* RetractableSidebar/RetractableSidebar.tsx */
2
3import { useEffect, useState } from "react";
4import classNames from "classnames";
5import styles from "./RetractableSidebar.module.scss";
6
7
8export default function RetractableSidebar({
9  opened,
10  onClickClose
11}: {
12  opened: boolean;
13  onClickClose: () => void;
14}) {
15  const [ isSidebarVisible, setIsSidebarVisible ] = useState( false );
16
17  const handleClose = () => {
18    setIsSidebarVisible( false );
19    onClickClose();
20  };
21
22  useEffect(() => {
23    if ( opened ) {
24      setIsSidebarVisible( true );
25    }
26  }, [ opened ] );
27
28  return (
29    <div
30      onClick={handleClose}
31      className={classNames(
32        styles.wrapper,
33        {
34          [styles.visible]: opened
35        }
36      )}
37    >
38      <aside
39        className={classNames(
40          styles.sidebar,
41          {
42            [styles.opened]: isSidebarVisible  
43          }
44        )}
45      >
46        <button onClick={handleClose}>
47          Close Sidebar
48        </button>
49      </aside>
50    </div>
51  );
52}

Summary of the Workflow Behind the Opening

In the end, the opening of the sidebar is the result of a thoroughly timed succession of events. Namely the following:

  1. The user clicks on 'Open Sidebar'.
  2. The components tree starts re-rendering consequently to the call of setIsSidebarOpened( true ), triggered in the App component.
  3. An opened prop equal to true is passed to RetractableSidebar, which as a result adds the .visible CSS class to the <div> wrapping the sidebar.
  4. The wrapping <div>, and so the blurred area, appears in the browser.
  5. While the rendering is still in progress, the callback of the useEffect() hook is fired and setIsSidebarVisible( true ) is executed, thus triggering a new rendering of the RetractableSidebar component.
  6. Now isSidebarVisible is true, and the .opened CSS class is added to the sidebar — the <aside> tag, in the JSX.
  7. The sidebar slides into view.

The key point to take in mind is that the animation doesn't take place during the same rendering which makes the blurred area to show up. At the contrary, the two events are carried out during two distinct, consecutive renderings of the RetractableSidebar component. And that is the very reason why we can, as users, appreciate the sliding animation.

Animation in two distinct renderings
The opening animation, just like the closing one, is actualised through two distinct, consecutive renderings.

Fixing the Sidebar's Closing Animation

From what we have learned so far, should come with no surprise that the closing animation appears broken. In fact, a quick look at the handleClose() callback is enough to understand the cause: we are removing the .visible and .opened classes at the same time, thus leaving no useful time for the closing animation to run.

So, the problem is the same we encountered for the opening animation, and the solution is exactly the same as well: we have to remove the .visible and .opened classes during two distinct renderings of the RetractableSidebar component. However, there is a but. This time around the blurred area, the sidebar's wrapper, has to be hidden only once the animation has drawn to the end.

Let's take a quick look at the handleClose() callback:

1const handleClose = () => {
2  setIsSidebarVisible( false );
3  onClickClose();
4};

In practice, what we have to do is to delay the call of onClickClose() for a time equal to the duration of the animation, because the rendering during which the .visible CSS class is removed from the wrapper element, is triggered in that very callback. Therefore, we have to let the global SCSS variable $animation-duration-ms to reach the TypeScript of our component. And to do that, we are going to use a separate SCSS module whose content is the following:

1/* styles/variables-export.module.scss */
2
3@import "./variables";
4
5:export {
6  animationDuration: $animation-duration;
7}

With this file in place, we can now introduce the delay we were talking about by rewriting the handleClose() callback as done in this almost-final version of our RetractableSidebar component:

1/* RetractableSidebar/RetractableSidebar.tsx */
2
3import { useEffect, useState } from "react";
4import classNames from "classnames";
5import styles from "./RetractableSidebar.module.scss";
6import scssVariables from "../styles/variables-export.module.scss";
7
8
9export default function RetractableSidebar({
10  opened,
11  onClickClose
12}: {
13  opened: boolean;
14  onClickClose: () => void;
15}) {
16  const [ isSidebarVisible, setIsSidebarVisible ] = useState( false );
17
18  const handleClose = () => {
19    const timeoutMilliseconds = Number( scssVariables.animationDurationMs );
20
21    setIsSidebarVisible( false );
22    setTimeout(() => onClickClose(), timeoutMilliseconds );
23  };
24
25  useEffect(() => {
26    if ( opened ) {
27      setIsSidebarVisible( true );
28    }
29  }, [ opened ] );
30
31  return (
32    // Same JSX as before
33  );
34}

Just a side note, the typecast in the handleClose() callback is necessary because setTimeout() expects its second argument to be of type number.

Summary of the Workflow Behind the Closing

Now that even the closing of the sidebar works as expected, I think it would be useful to summarise the succession of events occurring from the firing of a close action until the actual disappearance of the sidebar from the screen:

  1. The user clicks on either the 'Close Sidebar' button or the blurred area.
  2. handleClose() is executed and the RetractableSidebar component re-renders consequently to the call of setIsSidebarVisible( false ). Concurrently a timed callback, () => onClickClose(), is registered.
  3. During the rendering, the .opened CSS class is removed from the <aside> element, and the sidebar slides out.
  4. 300 milliseconds later, the duration of the animation, the timed callback is executed. So, onClickClose() is called and the components tree starts re-rendering consequently to the call of setIsSidebarOpened( false ), triggered in the App component.
  5. An opened prop equal to false is passed to RetractableSidebar, which as a consequence removes the .visible CSS class from the <div> wrapping the sidebar.
  6. The blurred area disappears.

The two distinct renderings of the RetractableSidebar component are, again, the stratagem which allows us to watch to the animated disappearance of the sidebar. And the delay between the two renderings is the keystone.

Fixing the Click-propagation Issue

There is only one last thing left to fix, a click propagation issue. I'll explain.

Have you tried to click on the red area of the sidebar? Well, as you can notice the click event propagates up to the wrapping <div>, causing the sidebar to close when it shouldn't.

The solution is to stop the clicks from propagating beyond the <aside> element by calling the event.stopPropagation() method, like so:

1export default function RetractableSidebar({
2  opened,
3  onClickClose
4}: {
5  opened: boolean;
6  onClickClose: () => void;
7}) {
8  // ...
9
10  return (
11    <div
12      onClick={handleClose}
13      className={classNames(
14        styles.wrapper,
15        {
16          [styles.visible]: opened
17        }
18      )}
19    >
20      <aside
21        onClick={event => event.stopPropagation()}
22        className={classNames(
23          styles.sidebar,
24          {
25            [styles.opened]: isSidebarVisible  
26          }
27        )}
28      >
29        <button onClick={handleClose}>
30          Close Sidebar
31        </button>
32      </aside>
33    </div>
34  );
35}

Of course, there are other approaches we can use to put in place the sliding animation, and a number of variations to the RetractableSidebar component, too. For instance, we might want to simplify the design by removing the blurred area altogether, and applying a box-shadow to the sidebar so as to make it look like it is floating over the page — in such case, we would need a way to intercept clicks falling outside the sidebar. That just to name one. But after all, only your imagination can draw a limit to how many are the ways to rethink the same piece of UI. What really matters now, however, is to take action!