TL;DR
This article goes through the steps to build a close button as a React component.
You will also learn
- HTML coding best practices for close buttons (Section 1)
- How to style an SVG icon button (Section 3)
- Best practices for styling the button focus state (Section 4)
- How to display the ripple animation after clicking the button (Section 5)
The result is available as a CodeSandbox demo.
Introduction
The web app I’m developing features a view of Google Maps embedded in full-screen. For the user to search a place on the map, to save a searched place, to see the detail of a saved place, and to change the user setting, the app needs a popup window to provide information and relevant buttons to the user.
One of those buttons is the close button, that little cross icon at the top-right corner of a popup. Since it will be used for all these popups, it is best to build it as a reusable component.
It turns out that making a close button component involves quite a bit of web dev techniques. In this article, I’d like to share what I have learned out of my experience of creating a close button as a React component.
1. HTML
When I start composing a React component, the first thing I do is to forget about React. Instead I solely focus on HTML. What HTML code do I want the React component to render? That’s the question I ask myself.
A little investigation into the best practices of coding HTML for the close button led me to discover two excellent articles on the subject: Stanton (2020) and Matuzovic (2020). Below is what I digest from them.
1.1 SVG Icon
Using the character ✕ as an “icon” for the close button is not a good idea in terms of accessibility: for screen reader users, it does not mean “close something”. Matuzovic (2020) writes:
✕ doesn’t represent close or crossed out, it’s the multiplication sign, like in 2 ✕ (times) 2. Don’t use it for close buttons.
Instead, the best approach is to use an SVG icon, which itself has no literal meaning. For my purpose, using SVG is also beneficial in terms of visual consistency: since I’m developing a web app that heavily features Google Maps (see Day 1 of this blog series), an SVG icon taken from Google‘s Material Icons will achieve visual consistency in my web app design.
Material Icons’s “Close” icon has the following SVG code:
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="#000000"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"
/>
</svg>
What I actually need is the viewBox
attribute value and the second <path>
element.
- The
xmlns
attribute is unnecessary if the SVG code is embedded in HTML (see MDN Web Docs on<svg>
). - The
height
andwidth
attributes are redundant when we use CSS to set the size (see Bellamy-Royds 2015). - So is the
fill
attribute (we can use CSS). - And I always wonder what the first
<path>
element is for, whenever I download Material Icons SVG images (please comment to this article if you have an idea).
So the first attempt to code HTML for the close button is something like this:
<button type="button">
<svg viewBox="0 0 24 24">
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"
/>
</svg>
</button>
1.2 Button label for screen reader users
However, the above HTML code would confuse screen reader users: the button has no label. To let them know what the button does, we need to add aria-label
:
<button aria-label="Close" type="button"> <!-- REVISED -->
<svg aria-hidden="true" viewBox="0 0 24 24"> <!-- REVISED -->
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"
/>
</svg>
</button>
Here I apply the lesson learned on Day 16 of this blog series: “Icon buttons should be labelled with aria-label
” (see the link for detail). It also happens to be what Stanton (2020) concluses as the best practice for close buttons.
You might wonder why the <svg>
element is hidden for screen readers with aria-hidden="true"
. Once the button is labeled with aria-label
, screen reader users do not need to know whether there is an SVG icon on the button or not. So we can remove it for them.
1.3 “Close what?”
One additional consideration comes from the following passage from Matuzovic (2020):
Sometimes it makes sense to use more descriptive labels like “Close dialog”, “Close gallery”, or “Close ad”.
So we need some flexibility in the value of aria-label
, to make it reusable in different contexts. That means the aria-label
value needs to be a prop of the React component.
2. React component
To reuse the above HTML structure whenever we render a close button, we can create a Reaxt component. Here‘s how I go about it.
2.1 ARIA attributes as props
To allow aria-label
to differ, I make it as a prop:
export const CloseButton = ({ ariaLabel }) => {
return (
<button aria-label={ariaLabel} type="button">
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
</svg>
</button>
);
};
To make it explicit that aria-label
is always required, I add PropTypes
:
import PropTypes from "prop-types"; // ADDED
export const CloseButton = ({ ariaLabel }) => {
return (
<button
aria-controls={ariaControls}
aria-expanded={ariaExpanded}
aria-label={ariaLabel}
type="button"
>
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
</svg>
</button>
);
};
// ADDED FROM HERE
CloseButton.propTypes = {
ariaLabel: PropTypes.string.isRequired,
};
// ADDED UNTIL HERE
So, in case I forget giving the ariaLabel
prop to the CloseButton
component, React will complain in the JavaScript console. (Which indeed happened when I was creating a demo for this article with CodeSandbox.)
Maybe it’s time for me to start using TypeScript, though.
2.2 Click handler
Of course, we need to attach a click event handler to the <button>
element so that the pressing of it will close something.
For this purpose, I add handleClick
prop:
import PropTypes from "prop-types";
export const CloseButton = ({
ariaLabel,
handleClick, // ADDED
}) => {
return (
<button
aria-label={ariaLabel}
onClick={handleClick} // ADDED
type="button"
>
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
</svg>
</button>
);
};
CloseButton.propTypes = {
ariaLabel: PropTypes.string.isRequired,
clickHandler: PropTypes.string.isRequired, // ADDED
};
Again, I add the prop type so React will complain if I forget adding the clickHandler
prop.
Now, let’s move on to style the close button.
3. CSS
3.1 Using Styled Components
To style React components, I always use Styled Components:
import PropTypes from 'prop-types';
import { Button } from '../styled-components/Button'; // ADDED
export const CloseButton = ({
ariaLabel,
handleClick
}) => {
return (
<Button // REVISED
aria-label={ariaLabel}
onClick={handleClick}
type="button"
>
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
</svg>
</Button> {/* REVISED */}
);
};
CloseButton.propTypes = {
ariaLabel: PropTypes.string.isRequired,
clickHandler: PropTypes.string.isRequired,
};
where the Button
styled component is defined in a separate file:
// styled-components/Button.js
import styled from "styled-components";
export const Button = styled.button`
/* CSS declarations to be inserted */
`;
In this file, let’s add CSS declarations to style the close button step by step.
3.2 Reset the default button style
The button
element has a particular set of default style. I want to reset it first:
// styled-components/Button.js
import styled from "styled-components";
// ADDED FROM HERE
const resetStyle = `
background-color: rgba(255, 255, 255, 0);
border: none;
`;
// ADDED UNTIL HERE
export const Button = styled.button`
${resetStyle} /* ADDED */
`;
The kind of the close button that I’m after is minimally styled, just a cross mark icon without borders or solid background color.
For transparency, I always use the transparent white (i.e., rgba(255, 255, 255, 0)
) rather than the named color keyword of transparent
ever since I learned about cross-browser inconsistency: Safari interprets transparent
as the transparent black (see Coyier 2017).
And I use a variable resetStyle
to define a set of CSS declarations for code readability. This is what I like about CSS-in-JS.
3.3 Set clickable area
Next, let‘s shape the close button.
Although there is no border, when the keyboard user focuses it with the Tab key, I want it to show a circular focus ring, rather than a square one. The border-radius: 50%;
does the job.
For the size of the button, Google recommends the 48x48 pixel area as a minimum touch target size, because:
“The 48x48 pixel area corresponds to around 9mm, which is about the size of a person’s finger pad area.” (Gash et al. 2020)
So the diameter of the button is set to be 48px.
Therefore, we have:
// styled-components/Button.js
import styled from 'styled-components';
...
// ADDED FROM HERE
const setClickableArea = `
--minimum-target-size: 48px;
border-radius: 50%;
height: var(--minimum-target-size);
width: var(--minimum-target-size);
`;
// ADDED UNTIL HERE
export const Button = styled.button`
${resetStyle}
${setClickableArea} /* ADDED */
`;
Using a CSS variable name (--minimum-target-size
), I can embed the logic behind the numbers into the CSS code.
3.4 Center-align the icon
Now we need to position the SVG icon inside the clickable area. I want it to be center-aligned both vertically and horizontally. So I use flexbox:
// styled-components/Button.js
import styled from 'styled-components';
...
// ADDED FROM HERE
const centerAlignIcon = `
align-items: center;
display: flex;
justify-content: center;
`;
// ADDED UNTIL HERE
export const Button = styled.button`
${resetStyle}
${setClickableArea}
${centerAlignIcon} /* ADDED */
`;
3.5 Style the icon
Now let’s style the icon:
// styled-components/Button.js
import styled from 'styled-components';
...
// ADDED FROM HERE
const styleIcon = `
& svg {
fill: var(--button-label-color-default);
height: calc(var(--minimum-target-size) * 0.75);
width: calc(var(--minimum-target-size) * 0.75);
}
`;
// ADDED UNTIL HERE
export const Button = styled.button`
${resetStyle}
${setClickableArea}
${centerAlignIcon}
${styleIcon} /* ADDED */
`;
To specify the color of an SVG icon, we need the fill
property. I use a CSS variable so it will switch between light and dark modes (see Dodds 2020 for how).
The SVG icon’s height
and width
are set to be three-quarters of the minimum target size of 48px, which makes the close button look decent when the focus ring appears around it. Rather than hard-coding 36px, using the calc()
CSS function and the CSS variable of --minimum-target-size
makes clear the logic behind the choice of 36px.
That’s all for the default style.
Notice that I don’t include any CSS code for positioning the close button at the top-right corner of a dialog. That’s the job for the CSS code of a dialog element (<div role="dialog>
). We will see an example of it in the future article of this blog series.
4. Focus state style
4.1 Consistency with cloud buttons
On Day 7 of this blog series (see Section 8), I styled the focus ring for cloud-shaped buttons in a shade of blue rgb(69,159,189)
and made it glow with drop-shadow(0 0 5px rgb(69,159,189))
. Also, I made the button label's color change to rgb(3, 3, 3)
.
To achieve visual consistency, I want the close button to have the same focus ring. So the first attempt is something like this:
// styled-components/Button.js
import styled from 'styled-components';
...
// ADDED FROM HERE
const glowFocusRing = `
border: 1px solid rgb(69, 159, 189);
box-shadow: 0 0 5px rgb(69, 159, 189);
`;
const darkenButtonLabel = `
fill: rgb(3, 3, 3);
`;
const supportHighContrastMode = `
outline: 1px solid transparent;
`
const styleFocusState = `
&:focus {
${glowFocusRing}
${supportHighContrastMode}
}
&:focus svg {
${darkenButtonLabel}
}
`;
// ADDED UNTIL HERE
export const Button = styled.button`
${resetStyle}
${setClickableArea}
${centerAlignIcon}
${styleIcon}
${styleFocusState} /* ADDED */
`;
The above code gives us a focus ring like this:
4.2 Fallback for Windows High Contrast Mode
The above CSS code includes the declaration of outline: 1px solid transparent
. This is for Windows High Contrast Mode, which removes the color of border and box shadow and instead renders any outline in solid white (even if it’s transparent). It’s becoming the norm (as far as I see) to include a transparent outline when the custom focus style involves border and box shadow. See Higley (2020) for detail.
An added bonus is that it also removes the default focus ring by overriding the outline
property.
4.3 Remove focus style for touch device users and mouse users
The focus state style for a button is unnecessary for smartphone/tablet users and mouse users. We only need to style the focus state for keyboard users.
However, for all the browsers except Safari, the :focus
pseudo selector gets activated when the button is clicked (as pointed out by Underhill 2021). So there will be a flash of the focus style when touch device users tap the button (or when mouse users click the button). That’s not desirable.
A solution is the :focus-visible
pseudo selector which gets activated only when the button is focused with keyboard strokes. Underhill (2021) provides an excellent overview of :focus-visible
.
But there is an issue of handling those “legacy” browsers (including the versions of Safari released before March 2022) that do not support :focus-visible
. Consequently, we also need to use the :focus
pseudo selector so that keyboard users with old devices won’t be ignored.
Lauke (2018) proposes to define the focus state style with :focus
for legacy browsers and then to remove the focus state style with the following pseudo selector:
:focus:not(:focus-visible)
which refers to the clicking of a button by smartphone, tablet and mouse users.
Following this technique, I revise the previous code for styling the focus state as follows:
// styled-components/Button.js
import styled from 'styled-components';
...
const glowFocusRing = `
border: 1px solid rgb(69, 159, 189);
box-shadow: 0 0 5px rgb(69, 159, 189);
`;
const darkenButtonLabel = `
fill: rgb(3, 3, 3);
`;
const supportHighContrastMode = `
outline: 1px solid transparent;
`;
// ADDED FROM HERE
const removeFocusRing = `
border: none;
box-shadow: none;
`;
const resetButtonLabelColor = `
fill: rgb(90, 90, 90);
`;
// ADDED UNTIL HERE
const styleFocusState = `
&:focus {
${glowFocusRing}
${supportHighContrastMode}
}
&:focus svg {
${darkenButtonLabel}
}
/* ADDED FROM HERE */
&:focus:not(:focus-visible) {
${removeFocusRing}
}
&:focus:not(:focus-visible) svg {
${resetButtonLabelColor}
}
/* ADDED UNTIL HERE */
`;
export const Button = styled.button`
${resetStyle}
${setClickableArea}
${centerAlignIcon}
${styleIcon}
${styleFocusState}
`;
where rgb(90, 90, 90)
is the default color for button icons.
5. Active state style
The active state (or what Material Design calls the “pressed” state) is critical to tell the user whether the button is pressed or not. If the button doesn‘t visually respond to the user’s action, the user might think the app is not working, especially when the network connection is slow or the user’s device is slow (like my iPad Mini 2 bought in 2015).
For some reason, many web designers and web developers are not keen on styling the button active state. Lorenz (2019) showcases the CodePen demos of “Top 50 CSS Buttons”. When I click these buttons, very few of them clealry indicate that the button is indeed pressed.
5.1 Ripple effect
Today the most popular active state style is probably the ripple effect, popularized by Google with its Material Design.
And I follow this practice because, due to the popularity, the user can immediately tell what ripple animation means. Strictly speaking, it is not fully consistent with the design concept of my web app (see Day 2 of this blog series). For creating the minimum viable product, however, the ripple effect is good enough, and relatively easy to implement.
5.2 Adding the ripple to the DOM
Cameron (2020) provides a React code snippet for the ripple effect. Adapting this snippet, I first write down a function called createRipple
:
// /utils/createRipple.js
export function createRipple(event) {
const button = event.currentTarget;
const diameter = Math.max(button.clientWidth, button.clientHeight);
const radius = diameter / 2;
const circle = document.createElement('span');
circle.style.width = circle.style.height = `${diameter}px`;
circle.style.left = `${event.offsetX - radius}px`;
circle.style.top = `${event.offsetY - radius}px`;
circle.classList.add('ripple');
circle.addEventListener('animationend', () => button.removeChild(circle));
button.appendChild(circle);
}
The core idea is to create a temporary <span>
element as a child of the <button>
element, to shape it as a circle, and to set its left
and top
CSS properties to be dependent on where the user clicks on the button (event.offsetX
and event.offsetY
). For more detail, see Cameron (2020).
Note that the above code is revised from the original snippet by Cameron (2020). I have incorporated a couple of comments to the article:
-
Gonzalez (2020) for the use of
offsetX
andoffsetY
-
Jonas (2020) for removing the
<span>
element once animation is over.
5.3 Style the ripple
The above code attaches a class ripple
to the <span>
element. This class is used to style the ripple:
// /styled-components/Button.js
import styled, {css, keyframes} from 'styled-components'; // REVISED
...
// ADDED FROM HERE
const ripple = keyframes`
to {
transform: scale(4);
opacity: 0;
}
`;
const styleActiveState = css`
overflow: hidden;
position: relative;
& .ripple {
animation: ${ripple} 600ms linear;
background-color: var(--ripple-color);
border-radius: 50%;
position: absolute;
transform: scale(0);
}
`;
// ADDED UNTIL HERE
export const Button = styled.button`
${resetStyle}
${setClickableArea}
${centerAlignIcon}
${styleIcon}
${styleFocusState}
${styleActiveState} /* ADDED */
`;
At the top of the code, I import the css
and keyframes
helper functions, which is necessary to use CSS animation with Styled Components. The keyframes
is for defining animation, and the css
is required whenever CSS declarations include the animation keyframe (see Styled Components Docs for detail).
The animation defined with keyframes
involves transform
and opacity
properties. The <span>
element is animated to scale from zero to four with transform: scale()
. It’s not clear why four. But I follow Cameron (2020), who must have reached this magnic number by trial and error.
And opacity
is animated from 1 (default) to 0, imitating the vanishing water ripples.
Animation duration is 600ms, which is another magic number from Cameron (2020). Animation easing is linear
: water ripples move at a constant speed according to this Reddit discussion.
The color of the ripple is set with --ripple-color
, which is defined (elsewhere in the code base) as rgba(3, 3, 3, 0.33)
for light mode and rgba(255, 255, 255, 0.4)
for dark mode. According to the Material Design guideline, the (initial) opacity of the ripple should be higher the darker the background.
Finally, the <button>
element itself needs to be styled with
overflow: hidden;
position: relative;
so that the <span>
element’s position is contained inside the button and that the ripple disappears when it hits the edges of the button.
5.4 Create the ripple on the click event
Then execute the createRipple
function when the user clicks the button (but before executing the close button’s functionality given by the handleClick
prop):
import PropTypes from 'prop-types';
import { Button } from '../styled-components/Button';
import { createRipple } from '../utils/createRipple'; // ADDED
export const CloseButton = ({
ariaLabel,
handleClick,
}) => {
// ADDED FROM HERE
const clickHandler = (event) => {
createRipple(event);
handleClick();
};
// ADDED UNTIL HERE
return (
<Button
aria-label={ariaLabel}
onClick={clickHandler} // REVISED
type="button"
>
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
</svg>
</Button>
);
};
CloseButton.propTypes = {
ariaLabel: PropTypes.string.isRequired,
clickHandler: PropTypes.string.isRequired,
};
The snippet by Cameron (2020) ends here. But I realize that there is one thing missing: handling the button click with a keyboard.
5.5 Handle keyboard users
The above code won’t create the ripple effect when keyboard users press the button with the Enter key.
Let’s assume that the button is pressed at its center in this case. So the createRipple
function is revised as follows:
// /utils/createRipple.js
export function createRipple(event) {
const button = event.currentTarget;
const diameter = Math.max(button.clientWidth, button.clientHeight);
const radius = diameter / 2;
const circle = document.createElement('span');
circle.style.width = circle.style.height = `${diameter}px`;
// REVISED FROM HERE
if (event.key === 'Enter') {
circle.style.left = 0;
circle.style.top = 0;
} else {
circle.style.left = `${event.offsetX - radius}px`;
circle.style.top = `${event.offsetY - radius}px`;
}
// REVISED UNTIL HERE
circle.classList.add('ripple');
circle.addEventListener('animationend', () => button.removeChild(circle));
button.appendChild(circle);
}
Also the CloseButton
component needs to be revised as follows:
import PropTypes from 'prop-types';
import { Button } from '../styled-components/Button';
import { createRipple } from '../utils/createRipple';
export const CloseButton = ({
ariaLabel,
handleClick,
}) => {
const clickHandler = (event) => {
createRipple(event);
handleClick();
};
// ADDED FROM HERE
const keydownHandler = (event) => {
if (event.key === 'Enter') {
event.preventDefault(); // otherwise click event will be fired as well
createRipple(event);
handleClick();
}
};
// ADDED UNTIL HERE
return (
<Button
aria-label={ariaLabel}
onClick={clickHandler}
onKeyDown={keydownHandler} // ADDED
type="button"
>
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
</svg>
</Button>
);
};
CloseButton.propTypes = {
ariaLabel: PropTypes.string.isRequired,
clickHandler: PropTypes.string.isRequired,
};
Demo
Here’s a CodeSandbox demo of what the code in this article achieves. Try to click the close button on the top right. It won’t close anything, but it does give you the UI feedback of the ripple effect!
References
Bellamy-Royds, Amelia (2015) “How to Scale SVG”, CSS-Tricks, Jan 6, 2015.
Cameron, Bret (2020) “How to Recreate the Ripple Effect of Material Design Buttons”, CSS-Tricks, Oct 12, 2020.
Coyier, Chris (2017) “A Thing To Know about Gradients and ‘Transparent Black’”, CSS-Tricks, Jan 10, 2017.
Dodds, Kent C. (2020) “Use CSS Variables instead of React Context”, Epic React, Oct 2020.
Gash, Dave, Meggin Kearney, Rachel Andrew, Rob Dodson, and Patrick H. Lauke (2020) “Accessible tap targets”, web.dev, Mar 31, 2020.
Gonzalez, Angelo (2020) “Awesome work! For anyone trying to apply this to absolute positioned elements...”, comment to CSS-Tricks, Oct 14, 2020.
Higley, Sarah (2020) “Quick Tips for High Contrast Mode”, sarahmhigley.com, May 26, 2020.
Jonas (2020) “Thanks for this nice article! One thing I did differently is...”, comment to CSS-Tricks, Oct 14, 2020.
Lauke, Patrick H. (2018) “:focus-visible and backwards compatibility”, TPGi, Mar 23, 2018.
Lorenz (2019) “Top 50 CSS Buttons (+ animations)”, Dev Community, Jun 9, 2019.
Matuzovic, Manuel (2020) “#20 HTMHell special: close buttons”, HTMHell, May 23, 2020.
Stanton, Benjy (2020) “Accessible close buttons”, benjystanton.co.uk, Apr 30, 2020.
Underhill, Martin (2021) “Refining focus styles with focus-visible”, tempertemper, May 25, 2021.
Top comments (0)