A little while back, Ganesh Dahal penned a post here on CSS-Tricks responding to a tweet that asked about adding CSS box shadows on WordPress blocks and elements. There’s a lot of great stuff in there that leverages new features that shipped in WordPress 6.1 that provide controls for applying shadows to things directly in the Block Editor and Site Editor UI.
Ganesh touched briefly on button elements in that post. I want to pick that up and go deeper into approaches for styling buttons in WordPress block themes. Specifically, we’re going to crack open a fresh theme.json file and break down various approaches to styling buttons in the schema.
Why buttons, you ask? That’s a good question, so let’s start with that.
The different types of buttons
When we’re talking about buttons in the context of the WordPress Block Editor, we have to distinguish between two different types:
Child blocks inside of the Buttons block
Buttons that are nested inside other block (e.g. the Post Comments Form block)
If we add both of these blocks to a template, they have the same look by default.
As we can see, the HTML tag names are different. It’s the common classes — .wp-block-button and .wp-element-button — that ensure consistent styling between the two buttons.
If we were writing CSS, we would target these two classes. But as we know, WordPress block themes have a different way of managing styles, and that’s through the theme.json file. Ganesh also covered this in great detail, and you’d do well giving his article a read.
So, how do we define button styles in theme.json without writing actual CSS? Let’s do it together.
Creating the base styles
theme.json is a structured set of schema written in property:value pairs. The top level properties are called “sections”, and we’re going to work with the styles section. This is where all the styling instructions go.
We’ll focus specifically on the elements in the styles. This selector targets HTML elements that are shared between blocks. This is the basic shell we’re working with:
That button corresponds to HTML elements that are used to mark up button elements on the front end. These buttons contain HTML tags that could be either of our two button types: a standalone component (i.e. the Button block) or a component nested within another block (e.g. the Post Comment block).
Rather than having to style each individual block, we create shared styles. Let’s go ahead and change the default background and text color for both types of buttons in our theme. There’s a color object in there that, in turn, supports background and text properties where we set the values we want:
If crack open DevTools and have a look at the CSS that WordPress generates for the buttons, we see that the .wp-element-button class adds the styles we defined in theme.json:
Those are our default colors! Next, we want to give users visual feedback when they interact with the button.
Implementing interactive button styles
Since this is a site all about CSS, I’d bet many of you are already familiar with the interactive states of links and buttons. We can :hover the mouse cursor over them, tab them into :focus, click on them to make them :active. Heck, there’s even a :visited state to give users a visual indication that they’ve clicked this before.
Notice the “structured” nature of this. We’re basically following an outline:
Elements
Element
Object
Property
Value
We now have a complete definition of our button’s default and interactive styles. But what if we want to style certain buttons that are nested in other blocks?
Styling buttons nested in individual blocks
Let’s imagine that we want all buttons to have our base styles, with one exception. We want the submit button of the Post Comment Form block to be blue. How would we achieve that?
This block is more complex than the Button block because it has more moving parts: the form, inputs, instructive text, and the button. In order to target the button in this block, we have to follow the same sort of JSON structure we did for the button element, but applied to the Post Comment Form block, which is mapped to the core/post-comments-form element:
Notice that we’re no longer working in elements anymore. Instead, we’re working inside blocks which is reserved for configuring actual blocks. Buttons, by contrast, are considered a global element since they can be nested in blocks, even though they are available as a standalone block too.
The JSON structure supports elements within elements. So, if there’s a button element in the Post Comment Form block, we can target it in the core/post-comments-form block:
This selector means that not only are we targeting a specific block — we’re targeting a specific element that is contained in that block. Now we have a default set of button styles that are applied to all buttons in the theme, and a set of styles that apply to specific buttons that are contained in the Post Comment Form block.
The CSS generated by WordPress has a more precise selector as a result:
And what if we want to define different interactive styles for the Post Comment Form button? It’s the same deal as the way we did it for the default styles, only those are defined inside the core/post-comments-form block:
WordPress automagically generates and applies the right classes to output these button styles. But what if you use a “hybrid” WordPress theme that supports blocks and full-site editing, but also contains “classic” PHP templates? Or what if you made a custom block, or even have a legacy shortcode, that contains buttons? None of these are handled by the WordPress Style Engine!
No worries. In all of those cases, you would add the .wp-element-button class in the template, block, or shortcode markup. The styles generated by WordPress will then be applied in those instances.
And there may be some situations where you have no control over the markup. For example, some block plugin might be a little too opinionated and liberally apply its own styling. That’s where you can typically go to the “Advanced” option in the block’s settings panel and apply the class there:
Wrapping up
While writing “CSS” in theme.json might feel awkward at first, I’ve found that it becomes second nature. Like CSS, there are a limited number of properties that you can apply either broadly or very narrowly using the right selectors.
And let’s not forget the three main advantages of using theme.json:
The styles are applied to buttons in both the front-end view and the block editor.
Your CSS will be compatible with future WordPress updates.
The generated styles work with block themes and classic themes alike — there’s no need to duplicate anything in a separate stylesheet.
If you have used theme.json styles in your projects, please share your experiences and thoughts. I look forward to reading any comments and feedback!
There are thousands of articles out there about buttons and links on the web; the differences and how to use them properly. Hey, I don’t mind. I wrote my own as well¹.
It’s such a common mistake on the web that it’s always worth repeating:
Is the intention to send someone to another URL? It’s a link in the form of <a href="">.
Is it to trigger some on-page interactivity? It’s a button in the form of <button>.
Any devition from from those and you better smurfing know what you are doing.
If you had a keyboard and your “e” key would only work 90% of the time, it would be infuriating. Reliability and trust in user interfaces is important to allow users to navigate content and application with ease. If you use the right elements, you support users.
Manuel Matuzović has a Button Cheat Sheet that is a lol-inducing ride about why literally everything other than a <button> isn’t as good as a button. Manuel links up Marcy Sutton’s epic The Links vs. Buttons Showdown (video), pitting the two against each other in a mock battle — “We’ll pit two HTML elements against each other in a crusade of better and worse, right and possible wrong. One element is triggered with the space bar, the other with the enter key. Who will win?” I don’t know whether to laugh or cry at how far we have to go to spread this information.
I think our article A Complete Guide to Links and Buttons is a pretty good example of beginner-oriented content, which is my favorite style of content to write and publish! But because there is so much beginner-oriented content on the web, the bar is pretty high if you want to make and impact and get enough SEO for anyone to even ever find it. So, in this case, the idea was to go big and write nearly as much as there is to write about the technical foundation of links and buttons. If you’ve got a knack for this kind of writing, reach out for sure.
Recently, while looking for some ideas on what to code as I have zero artistic sense so the only thing I can do is find pretty things that other people have come up with and remake them with clean and compact code… I came across these candy ghost buttons!
They seemed like the perfect choice for a cool little thing I could quickly code. Less than fifteen minutes later, this was my Chromium result:
The pure CSS candy ghost buttons.
I thought the technique was worth sharing, so in this article, we’ll be going through how I first did it and what other options we have.
The starting point
A button is created with… are you ready for this? A button element! This button element has a data-ico attribute in which we drop an emoji. It also has a stop list custom property, --slist, set in the style attribute.
After writing the article, I learned that Safari has a host of problems with clipping to text, namely, it doesn’t work on button elements, or on elements with display: flex (and perhaps grid too?), not to mention the text of an element’s children. Sadly, this means all of the techniques presented here fail in Safari. The only workaround is to apply all the button styles from here on a span element nested inside the button, covering its parent’s border-box. And, in case this helps anyone else who, like me, is on Linux without physical access to an Apple device (unless you count the iPhone 5 someone on the fourth floor — who you don’t want to bother with stuff like this more than twice a month anyway — bought recently), I’ve also learned to use Epiphany in the future.
For the CSS part, we add the icon in an ::after pseudo-element and use a grid layout on the button in order to have nice alignment for both the text and the icon. On the button, we also set a border, a padding, a border-radius, use the stop list, --slist, for a diagonal gradient and prettify the font.
There’s one thing to clarify about the code above. On the highlighted line, we set both the background-origin and the background-clip to border-box. background-origin both puts the 0 0 point for background-position in the top-left corner of the box it’s set to and gives us the box whose dimensions the background-size is relative to.
That is, if background-origin is set to padding-box, the 0 0 point for background-position is in the top left-corner of the padding-box. If background-origin is set to border-box, the 0 0 point for background-position is in the top-left corner of the border-box. If background-origin is set to padding-box, a background-size of 50% 25% means 50% of the padding-box width and 25% of the padding-box height. If background-origin is set to border-box, the same background-size of 50% 25% means 50% of the border-box width and 25% of the border-box height.
The default value for background-origin is padding-box, meaning that a default-sized 100% 100% gradient will cover the padding-box and then repeat itself underneath the border (where we cannot see it if the border is fully opaque). However, in our case, the border is fully transparent and we want our gradient to stretch across the entire border-box. This means we need to change the background-origin value to border-box.
The result after applying the base styles (live demo).
The simple, but sadly non-standard Chromium solution
This involves using three mask layers and compositing them. If you need a refresher on mask compositing, you can check out this crash course.
Note that in the case of CSS mask layers, only the alpha channel matters, as every pixel of the masked element gets the alpha of the corresponding mask pixel, while the RGB channels don’t influence the result in any way, so they may be any valid value. Below, you can see the effect of a purple to transparent gradient overlay versus the effect of using the exact same gradient as a mask.
Gradient overlay vs. the same gradient mask (live demo).
We’re going to start with the bottom two layers. The first one is a fully opaque layer, fully covering the entire border-box, meaning that it has an alpha of 1 absolutely everywhere. The other one is also fully opaque, but restricted (by using mask-clip) to the padding-box, which means that, while this layer has an alpha of 1 all across the padding-box, it’s fully transparent in the border area, having an alpha of 0 there.
If you have a hard time picturing this, a good trick is to think of an element’s layout boxes as nested rectangles, the way they’re illustrated below.
In our case, the bottom layer is fully opaque (the alpha value is 1) across the entire orange box (the border-box). The second layer, that we place on top of the first one, is fully opaque (the alpha value is 1) all across the red box (the padding-box) and fully transparent (with an alpha of 0) in the area between the padding limit and the border limit.
One really cool thing about the limits of these boxes is that corner rounding is determined by the border-radius (and, in the case of the padding-box, by the border-width as well). This is illustrated by the interactive demo below, where we can see how the corner rounding of the border-box is given by the border-radius value, while the corner rounding of the padding-box is computed as the border-radius minus the border-width (limited at 0 in case the difference is a negative value).
Now let’s get back to our mask layers, one being fully opaque all across the entire border-box, while the one on top of it is fully opaque across the padding-box area and fully transparent in the border area (between the padding limit and the border limit). These two layers get composited using the exclude operation (called xor in the non-standard WebKit version).
The name of this operation is pretty suggestive in the situation where the alphas of the two layers are either 0 or 1, as they are in our case — the alpha of the first layer is 1 everywhere, while the alpha of the second layer (that we place on top of the first) is 1 inside the padding-box and 0 in the border area between the padding limit and the border limit.
In this situation, it’s pretty intuitive that the rules of boolean logic apply — XOR-ing two identical values gives us 0, while XOR-ing two different ones gives us 1.
All across the padding-box, both the first layer and the second one have an alpha of 1, so compositing them using this operation gives us an alpha of 0 for the resulting layer in this area. However, in the border area (outside the padding limit, but inside the border limit), the first layer has an alpha of 1, while the second one has an alpha of 0, so we get an alpha of 1 for the resulting layer in this area.
This is illustrated by the interactive demo below, where you can switch between viewing the two mask layers separated in 3D and viewing them stacked and composited using this operation.
Before we move further, let’s discuss a few fine-tuning details about the CSS above.
First off, since the fully opaque layers may be anything (the alpha channel is fixed, always 1 and the RGB channels don’t mater), I usually make them red — only three characters! In the same vein, using a conic gradient instead of a linear one would also save us one character, but I rarely ever do that since we still have mobile browsers that support masking, but not conic gradients. Using a linear one ensures we have support all across the board. Well, save for IE and pre-Chromium Edge but, then again, not much cool shiny stuff works in those anyway.
Second, we’re using gradients for both layers. We’re not using a plain background-color for the bottom one because we cannot set a separate background-clip for the background-color itself. If we were to have the background-image layer clipped to the padding-box, then this background-clip value would also apply to the background-color underneath — it would be clipped to the padding-box too and we’d have no way to make it cover the entire border-box.
Third, we’re not explicitly setting a mask-clip value for the bottom layer since the default for this property is precisely the value we want in this case, border-box.
Fourth, we can include the standard mask-composite (supported by Firefox) in the mask shorthand, but not the non-standard one (supported by WebKit browsers).
And finally, we always set the standard version last so it overrides any non-standard version that may also be supported.
The result of our code so far (still cross-browser at this point) looks like below. We’ve also added a background-image on the root so that it’s obvious we have real transparency across the padding-box area.
The result after masking out the entire padding-box (live demo).
This is not what we want. While we have a nice gradient border (and by the way, this is my preferred method of getting a gradient border since we have real transparency all across the padding-box and not just a cover), we are now missing the text.
So the next step is to add back the text using yet another mask layer on top of the previous ones, this time one that’s restricted to text (while also making the actual text fully transparent so that we can see the gradient background through it) and XOR this third mask layer with the result of XOR-ing the first two (result that can be seen in the screenshot above).
The interactive demo below allows viewing the three mask layers both separated in 3D as well as stacked and composited.
Note that the text value for mask-clip is non-standard, so, sadly, this only works in Chrome. In Firefox, we just don’t get any masking on the button anymore and having made the text transparent, we don’t even get graceful degradation.
button { /* same base styles */ -webkit-text-fill-color: transparent; --full: linear-gradient(red 0 0); -webkit-mask: var(--full) text, var(--full) padding-box, var(--full); -webkit-mask-composite: xor; /* sadly, still same result as before :( */ mask: var(--full) padding-box exclude, var(--full); }
If we don’t want to make our button unusable this way, we should put the code that applies the mask and makes the text transparent in a @supports block.
The final result using the masking-only method (live demo).
I really like this method, it’s the simplest we have at this point and I’d really wish text was a standard value for mask-clip and all browsers supported it properly.
However, we also have a few other methods of achieving the candy ghost button effect, and although they’re either more convoluted or more limited than the non-standard Chromium-only one we’ve just discussed, they’re also better supported. So let’s take a look at those.
The extra pseudo-element solution
This involves setting the same initial styles as before, but, instead of using a mask, we clip the background to the text area.
button { /* same base styles */ background: linear-gradient(to right bottom, var(--slist)) border-box; -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent }
Just like before, we need to also make the actual text transparent, so we can see through it to the pastel gradient background behind it that is now clipped to its shape.
Alright, we have the gradient text, but now we’re missing the gradient border. So we’re going to add it using an absolutely positioned ::before pseudo-element that covers the entire border-box area of the button and inherits the border, border-radius and background from its parent (save for the background-clip, which gets reset to border-box).
$ b: .25em; button { /* same as before */ position: relative; border: solid $ b transparent; &::before { position: absolute; z-index: -1; inset: -$ b; border: inherit; border-radius: inherit; background: inherit; background-clip: border-box; content: ''; } }
Note that we’re using the border-width value ($ b) with a minus sign here. The 0 value would make the margin-box of the pseudo (identical to the border-box in this case since we have no margin on the ::before) only cover the padding-box of its button parent and we want it to cover the entire border-box. Also, the positive direction is inwards, but we need to go outwards by a border-width to get from the padding limit to the border limit, hence the minus sign — we’re going in the negative direction.
We’ve also set a negative z-index on this absolutely positioned element since we don’t want it to be on top of the button text and prevent us from selecting it. At this point, text selection is the only way we can distinguish the text from the background, but we’ll soon fix that!
The result after adding the gradient pseudo (live demo).
Note that since pseudo-element content isn’t selectable, the selection only includes the button’s actual text content and not the emoji in the ::after pseudo-element as well.
The next step is to add a two layer mask with an exclude compositing operation between them in order to leave just the border area of this pseudo-element visible.
button { /* same as before */ &::before { /* same as before */ --full: linear-gradient(red 0 0); -webkit-mask: var(--full) padding-box, var(--full); -webkit-mask-composite: xor; mask: var(--full) padding-box exclude, var(--full); } }
This is pretty much what we did for the actual button in one of the intermediate stages of the previous method.
The final result using the extra pseudo method (live demo).
I find this to be the best approach in most cases when we want something cross-browser and that doesn’t include IE or pre-Chromium Edge, none of which ever supported masking.
The border-image solution
At this point, some of you may be screaming at the screen that there’s no need to use the ::before pseudo-element when we could use a gradient border-image to create this sort of a ghost button — it’s a tactic that has worked for over three quarters of a decade!
However, there’s a very big problem with using border-image for pill-shaped buttons: this property doesn’t play nice with border-radius, as it can be seen in the interactive demo below. As soon as we set a border-image on an element with border-radius, we lose the corner rounding of the border, even through, funny enough, the background will still respect this rounding.
Still, this may be a simple solution in the case where don’t need corner rounding or the desired corner rounding is at most the size of the border-width.
In the no corner rounding case, save for dropping the now pointless border-radius, we don’t need to change the initial styles much:
button { /* same base styles */ --img: linear-gradient(to right bottom, var(--slist)); border: solid .25em; border-image: var(--img) 1; background: var(--img) border-box; -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
The result can be seen below, cross-browser (should be supported supported even in pre-Chromium Edge).
The no corner rounding result using the border-image method (live demo).
The trick with the desired corner rounding being smaller than the border-width relies on the way border-radius works. When we set this property, the radius we set represents the rounding for the corners of the border-box. The rounding for the corners of the padding-box (which is the inner rounding of the border) is the border-radius minus the border-width if this difference is positive and 0 (no rounding) otherwise. This means we have no inner rounding for the border if the border-radius is smaller than or equal to the border-width.
In this situation, we can use the inset() function as a clip-path value since it also offers the possibility of rounding the corners of the clipping rectangle. If you need a refresher on the basics of this function, you can check out the illustration below:
How the inset() function works.
inset() cuts out everything outside a clipping rectangle defined by the distances to the edges of the element’s border-box, specified the same way we’d specify margin, border or padding (with one, two, three or four values) and the corner rounding for this rectangle, specified the same way we’d specify border-radius (any valid border-radius value is also valid here).
In our case, the distances to the edges of the border-box are all 0 (we don’t want to chop anything off any of the edges of the button), but we have a rounding that has to be at most at big as the border-width so that not having any inner border rounding makes sense.
$ b: .25em; button { /* same as before */ border: solid $ b transparent; clip-path: inset(0 round $ b) }
Note that the clip-path is also going to cut out any outer shadows we may add on the button element, whether they’re added via box-shadow or filter: drop-shadow().
The small corner rounding result using the border-image method (live demo).
While this technique cannot achieve the pill shape look, it does have the advantage of having great support nowadays and it may be all we need in certain situations.
The three solutions discussed so far can be seen compiled in the demo below, which also comes with a YouTube link where you can see me code each of them from scratch if you prefer to learn by watching things being built on video rather than reading about them.
All these methods create real transparency in the padding-box outside the text, so they work for any background we may have behind the button. However, we also have a couple of other methods that may be worth mentioning, even though they come with restrictions in this department.
The cover solution
Just like the border-image approach, this is a pretty limited tactic. It doesn’t work unless we have a solid or a fixed background behind the button.
It involves layering backgrounds with different background-clip values, just like the cover technique for gradient borders. The only difference is that here we add one more gradient layer on top of the one emulating the background behind our button element and we clip this top layer to text.
$ c: #393939; html { background: $ c; } button { /* same as before */ --grad: linear-gradient(to right bottom, var(--slist)); border: solid .25em transparent; border-radius: 9em; background: var(--grad) border-box, linear-gradient($ c 0 0) /* emulate bg behind button */, var(--grad) border-box; -webkit-background-clip: text, padding-box, border-box; -webkit-text-fill-color: transparent; }
Sadly, this approach fails in Firefox due to an old bug — just not applying any background-clip while also making the text transparent produces a pill-shaped button with no visible text.
The all background-clip cover solution (live demo).
We could still make it cross-browser by using the cover method for the gradient border on a ::before pseudo and background-clip: text on the actual button, which is basically just a more limited version of the second solution we discussed — we still need to use a pseudo, but, since we use a cover, not a mask, it only works if we have a solid or fixed background behind the button.
On the bright side, this more limited version should also work in pre-Chromium Edge.
The cover solution on a pseudo for a solid background behind the button (live demo).
Below, there’s also the fixed background version.
$ f: url(balls.jpg) 50%/ cover fixed; html { background: $ f; } button { /* same as before */ &::before { /* same as before */ background: $ f padding-box, var(--grad) border-box } }
The cover solution on a pseudo for a fixed background behind the button (live demo).
Overall, I don’t think this is the best tactic unless we both fit into the background limitation and we need to reproduce the effect in browsers that don’t support masking, but support clipping the background to the text, such as pre-Chromium Edge.
The blending solution
This approach is another limited one as it won’t work unless, for each and every gradient pixel that’s visible, its channels have values that are either all bigger or all smaller than than the corresponding pixel of the background underneath the button. However, this is not the worst limitation to have as it should probably lead to our page having better contrast.
Here, we start by making the parts where we want to have the gradient (i.e. the text, icon and border) either white or black, depending on whether we have a dark theme with a light gradient or a light theme with a dark gradient, respectively. The rest of the button (the area around the text and icon, but inside the border) is the inverse of the previously chosen color (white if we set the color value to black and black otherwise).
In our case, we have a pretty light gradient button on a dark background, so we start with white for the text, icon and border, and black for the background. The hex channel values of our two gradient stops are ff (R), da (G), 5f (B) and f9 (R), 37 (G), 6b (B), so we’d be safe with any background pixels whose channel values are at most as big as min(ff, f9) = f9 for red, min(da, 37) = 37 for green and min(5f, 6b) = 5f for blue.
This means having a background-color behind our button with channel values that are smaller or equal to f9, 37 and 5f, either on its own as a solid background, or underneath a background-image layer we blend with using the multiply blend mode (which always produces a result that’s at least as dark as the darker of the two layers). We’re setting this background on a pseudo-element since blending with the actual body or the html doesn’t work in Chrome.
$ b: .25em; body::before { position: fixed; inset: 0; background: url(fog.jpg) 50%/ cover #f9375f; background-blend-mode: multiply; content: ''; } button { /* same base styles */ position: relative; /* so it shows on top of body::before */ border: solid $ b; background: #000; color: #fff; &::after { filter: brightness(0) invert(1); content: attr(data-ico); } }
Note that making the icon fully white means making it first black with brightness(0) and then inverting this black with invert(1).
We then add a gradient ::before pseudo-element, just like we did for the first cross-browser method.
button { /* same styles as before */ position: relative; &::before { position: absolute; z-index: 2; inset: -$ b; border-radius: inherit; background: linear-gradient(to right bottom, var(--slist); pointer-events: none; content: ''; } }
The only difference is that here, instead of giving it a negative z-index, we give it a positive z-index. That way it’s not just over the actual button, but also over the ::after pseudo and we set pointer-events to none in order to allow the mouse to interact with the actual button content underneath.
The result after adding a gradient pseudo on top of the black and white button (live demo).
Now the next step is to keep the black parts of our button, but replace the white parts (i.e., the text, icon and border) with the gradient. We can do this with a darken blend mode, where the two layers are the black and white button with the ::after icon and the gradient pseudo on top of it.
For each of the RGB channels, this blend mode takes the values of the two layers and uses the darker (smaller) one for the result. Since everything is darker than white, the resulting layer uses the gradient pixel values in that area. Since black is darker than everything, the resulting layer is black everywhere the button is black.
button { /* same styles as before */ &::before { /* same styles as before */ mix-blend-mode: darken; } }
Alright, but we’d only be done at this point if the background behind the button was pure black. Otherwise, in the case of a background whose every pixel is darker than the corresponding pixel of the gradient on our button, we can apply a second blend mode, this time lighten on the actual button (previously, we had darken on the ::before pseudo).
For each of the RGB channels, this blend mode takes the values of the two layers and uses the lighter (bigger) one for the result. Since anything is lighter than black, the resulting layer uses the background behind the button everywhere the button is black. And since a requirement is that every gradient pixel of the button is lighter than the corresponding pixel of the background behind it, the resulting layer uses the gradient pixel values in that area.
button { /* same styles as before */ mix-blend-mode: lighten; }
The light ghost button on top of a dark background (live demo).
For a dark gradient button on a light background, we need to switch up the blend modes. That is, use lighten on the ::before pseudo and darken on the button itself. And first of all, we need to ensure the background behind the button is light enough.
Let’s say our gradient is between #602749 and #b14623. The channel values of our gradient stops are 60 (R), 27 (G), 49 (B) and b1 (R), 46 (G), 23 (R), so the background behind the button needs to have channel values that are at least max(60, b1) = b1 for red, max(27, 46) = 46 for green and max(49, 23) = 49 for blue.
This means having a background-color on our button with channel values that are bigger or equal to b1, 46 and 49, either on its own as a solid background, or underneath a background-image layer, uses a screen blend mode (which always produces a result that’s at least as light as the lighter of the two layers).
We also need to make the button border, text and icon black, while setting its background to white:
$ b: .25em; section { background: url(fog.jpg) 50%/ cover #b14649; background-blend-mode: screen; } button { /* same as before */ border: solid $ b; background: #fff; color: #000; mix-blend-mode: darken; &::before { /* same as before */ mix-blend-mode: lighten } &::after { filter: brightness(0); content: attr(data-ico); } }
The icon in the ::after pseudo-element is made black by setting filter: brightness(0) on it.
The dark ghost button on top of a light background (live demo).
We also have the option of blending all the button layers as a part of its background, both for the light and dark theme, but, as mentioned before, Firefox just ignores any background-clip declaration where text is a part of a list of values and not the single value.
Well, that’s it! I hope you’re having (or had) a scary Halloween. Mine was definitely made horrific by all the bugs I got to discover… or rediscover, along with the reality that they haven’t been fixed in the meanwhile.
Let’s talk about disabled buttons. Specifically, let’s get into why we use them and how we can do better than the traditional disabled attribute in HTML (e.g. <button disabled> ) to mark a button as disabled.
There are lots of use cases where a disabled button makes a lot of sense, and we’ll get to those reasons in just a moment. But throughout this article, we’ll be looking at a form that allows us to add a number of tickets to a shopping cart.
This is a good baseline example because there’s a clear situation for disabling the “Add to cart” button: when there are no tickets to add to the cart.
But first, why disabled buttons?
Preventing people from doing an invalid or unavailable action is the most common reason we might reach for a disabled button. In the demo below, we can only add tickets if the number of tickets being added to the cart is greater than zero. Give it a try:
Allow me to skip the code explanation in this demo and focus our attention on what’s important: the “Add to cart” button.
<button type="submit" disabled="disabled"> Add to cart </button>
This button is disabled by the disabled attribute. (Note that this is a boolean attribute, which means that it can be written as disabled or disabled="disabled".)
Everything seems fine… so what’s wrong with it?
Well, to be honest, I could end the article right here asking you to not use disabled buttons because they suck, and instead use better patterns. But let’s be realistic: sometimes disabling a button is the solution that makes the most sense.
With that being said, for this demo purpose, we’ll pretend that disabling the “Add to cart” button is the best solution (spoiler alert: it’s not). We can still use it to learn how it works and improve its usability along the way.
Types of interactions
I’d like to clarify what I mean by disabled buttons being usable. You may think, If the button is disabled, it shouldn’t be usable, so… what’s the catch? Bear with me.
On the web, there are multiple ways to interact with a page. Using a mouse is one of the most common, but there are others, like sighted people who use the keyboard to navigate because of a motor impairment.
Try to navigate the demo above using only the Tab key to go forward and Tab + Shift to go backward. You’ll notice how the disabled button is skipped. The focus goes directly from the ticket input to the “dummy terms” link.
Using the Tab key, it changes the focus from the input to the link, skipping the “Add to cart” button.
Let’s pause for a second and recap the reason that lead us to disable the button in the first place versus what we had actually accomplished.
It’s common to associate “interacting” with “clicking” but they are two different concepts. Yes, click is a type of interaction, but it’s only one among others, like hover and focus.
In other words…
All clicks are interactions, but not all interactions are clicks.
Our goal is to prevent the click, but by using disabled, we are preventing not only the click, but also the focus, which means we might be doing as much harm as good. Sure, this behavior might seem harmless, but it causes confusion. People with a cognitive disability may struggle to understand why they are unable to focus the button.
In the following demo, we tweaked the layout a little. If you use a mouse, and hover over the submit button, a tooltip is shown explaining why the button is disabled. That’s great! But if you use only the keyboard, there’s no way of seeing that tooltip because the button cannot be focused with disabled. Ouch!
Using the mouse, the tooltip on the “Add to cart” button is visible on hover. But the tooltip is missing when using the Tab key.
Allow me to once again skip past the code explanation. I highly recommend reading “Inclusive Tooltips” by Haydon Pickering and “Tooltips in the time of WCAG 2.1” by Sarah Higley to fully understand the tooltip pattern.
ARIA to the rescue
The disabled attribute in a <button> is doing more than necessary. This is one of the few cases I know of where a native HTML attribute can do more harm than good. Using an ARIA attribute can do a better job, allowing us to add not only focus on the button, but do so consistently to create an inclusive experience for more people and use cases.
The disabled attribute correlates to aria-disabled="true". Give the following demo a try, again, using only the keyboard. Notice how the button, although marked disabled, is still accessible by focus and triggers the tooltip!
Using the Tab key, the “Add to cart” button is focused and it shows the tooltip.
Cool, huh? Such tiny tweak for a big improvement!
But we’re not done quite yet. The caveat here is that we still need to prevent the click programmatically, using JavaScript.
elForm.addEventListener('submit', function (event) { event.preventDefault(); /* prevent native form submit */ const isDisabled = elButtonSubmit.getAttribute('aria-disabled') === 'true'; if (isDisabled || isSubmitting) { // return early to prevent the ticket from being added return; } isSubmitting = true; // ... code to add to cart... isSubmitting = false; })
You might be familiar with this pattern as a way to prevent double clicks from submitting a form twice. If you were using the disabled attribute for that reason, I’d prefer not to do it because that causes the temporary loss of the keyboard focus while the form is submitting.
The difference between disabled and aria-disabled
You might ask: if aria-disabled doesn’t prevent the click by default, what’s the point of using it? To answer that, we need to understand the difference between both attributes:
Feature / Attribute
disabled
aria-disabled="true"
Prevent click
✅
❌
Prevent hover
❌
❌
Prevent focus
✅
❌
Default CSS styles
✅
❌
Semantics
✅
✅
The only overlap between the two is semantics. Both attributes will announce that the button is indeed disabled, and that’s a good thing.
Contrary to the disabled attribute, aria-disabled is all about semantics. ARIA attributes never change the application behavior or styles by default. Their only purpose is to help assistive technologies (e.g. screen readers) to announce the page content in a more meaningful and robust way.
So far, we’ve talked about two types of people, those who click and those who Tab. Now let’s talk about another type: those with visual impairments (e.g. blindness, low vision) who use screen readers to navigate the web.
The VoiceOver screen reader skips the “Add to cart” button when using the Tab key.
Once again, this is a very minimal form. In a longer one, looking for a submit button that isn’t there right away can be annoying. Imagine a form where the submit button is hidden and only visible when you completely fill out the form. That’s what some people feel like when the disabled attribute is used.
Fortunately, buttons with disabled are not totally unreachable by screen readers. You can still navigate each element individually, one by one, and eventually you’ll find the button.
The VoiceOver screen reader is able to find and announce the “Add to cart” button.
Although possible, this is an annoying experience. On the other hand, with aria-disabled, the screen reader will focus the button normally and properly announce its status. Note that the announcement is slightly different between screen readers. For example, NVDA and JWAS say “button, unavailable” but VoiceOver says “button, dimmed.”
The VoiceOver screen reader can find the “Add to cart” button using Tab key because of aria-disabled.
I’ve mapped out how both attributes create different user experiences based on the tools we just used:
Tool / Attribute
disabled
aria-disabled="true"
Mouse or tap
Prevents a button click.
Requires JS to prevent the click.
Tab
Unable to focus the button.
Able to focus the button.
Screen reader
Button is difficult to locate.
Button is easily located.
So, the main differences between both attributes are:
disabled might skip the button when using the Tab key, leading to confusion.
aria-disabled will still focus the button and announce that it exists, but that it isn’t enabled yet; the same way you might perceive it visually.
This is the case where it’s important to acknowledge the subtle difference between accessibility and usability. Accessibility is a measure of someone being able to use something. Usability is a measure of how easy something is to use.
Given that, is disabled accessible? Yes. Does it have a good usability? I don’t think so.
Can we do better?
I wouldn’t feel good with myself if I finished this article without showing you the real inclusive solution for our ticket form example. Whenever possible, don’t use disabled buttons. Let people click it at any time and, if necessary, show an error message as feedback. This approach also solves other problems:
Less cognitive friction: Allow people to submit the form at any time. This removes the uncertainty of whether the button is even disabled in the first place.
Color contrast: Although a disabled button doesn’t need to meet the WCAG 1.4.3 color contrast, I believe we should guarantee that an element is always properly visible regardless of its state. But that’s something we don’t have to worry about now because the button isn’t disabled anymore.
Final thoughts
The disabled attribute in <button> is a peculiar case where, although highly known by the community, it might not be the best approach to solve a particular problem. Don’t get me wrong because I’m not saying disabled is always bad. There are still some cases where it still makes sense to use it (e.g. pagination).
To be honest, I don’t see the disabled attribute exactly as an accessibility issue. What concerns me is more of a usability issue. By swapping the disabled attribute with aria-disabled, we can make someone’s experience much more enjoyable.
This is yet one more step into my journey on web accessibility. Over the years, I’ve discovered that accessibility is much more than complying with web standards. Dealing with user experiences is tricky and most situations require making trade-offs and compromises in how we approach a solution. There’s no silver bullet for perfect accessibility.
Our duty as web creators is to look for and understand the different solutions that are available. Only then we can make the best possible choice. There’s no sense in pretending the problems don’t exist.
At the end of the day, remember that there’s nothing preventing you from making the web a more inclusive place.
Bonus
Still there? Let me mention two last things about this demo that I think are worthy:
1. Live Regions will announce dynamic content
In the demo, two parts of the content changed dynamically: the form submit button and the success confirmation (“Added [X] tickets!”).
These changes are visually perceived, however, for people with vision impairments using screen readers, that just ain’t the reality. To solve it, we need to turn those messages into Live Regions. Those allow assistive technologies to listen for changes and announce the updated messages when they happen.
There is a .sr-only class in the demo that hides a <span> containing a loading message, but allows it to be announced by screen readers. In this case, aria-live="assertive" is applied to the <span> and it holds a meaningful message after the form is submitting and is in the process of loading. This way, we can announce to the user that the form is working and to be patient as it loads. Additionally, we do the same to the form feedback element.
<button type="submit" aria-disabled="true"> Add to cart <span aria-live="assertive" class="sr-only js-loadingMsg"> <!-- Use JavaScript to inject the the loading message --> </span> </button> <p aria-live="assertive" class="formStatus"> <!-- Use JavaScript to inject the success message --> </p>
Note that the aria-live attribute must be present in the DOM right from the beginning, even if the element doesn’t hold any message yet, otherwise, Assistive Technologies may not work properly.
Form submit feedback message being announced by the screen reader.
There’s much more to tell you about this little aria-live attribute and the big things it does. There are gotchas as well. For example, if it is applied incorrectly, the attribute can do more harm than good. It’s worth reading “Using aria-live” by Ire Aderinokun and Adrian Roselli’s “Loading Skeletons” to better understand how it works and how to use it.
2. Do not use pointer-events to prevent the click
This is an alternative (and incorrect) implementation that I’ve seen around the web. This uses pointer-events: none; in CSS to prevent the click (without any HTML attribute). Please, do not do this. Here’s anuglyPen that will hopefully demonstrate why. I repeat,do notdo this.
Although that CSS does indeed prevent a mouse click, remember that it won’t prevent focus and keyboard navigation, which can lead to unexpected outcomes or, even worse, bugs.
In other words, using this CSS rule as a strategy to prevent a click, is pointless (get it?). 😉
When I first discovered Material Design, I was particularly inspired by its button component. It uses a ripple effect to give users feedback in a simple, elegant way.
How does this effect work? Material Design’s buttons don’t just sport a neat ripple animation, but the animation also changes position depending on where each button is clicked.
We can achieve the same result. We’ll start with a concise solution using ES6+ JavaScript, before looking at a few alternative approaches.
HTML
Our goal is to avoid any extraneous HTML markup. So we’ll go with the bare minimum:
<button>Find out more</button>
Styling the button
We’ll need to style a few elements of our ripple dynamically, using JavaScript. But everything else can be done in CSS. For our buttons, it’s only necessary to include two properties.
button { position: relative; overflow: hidden; }
Using position: relative allows us to use position: absolute on our ripple element, which we need to control its position. Meanwhile, overflow: hidden prevents the ripple from exceeding the button’s edges. Everything else is optional. But right now, our button is looking a bit old school. Here’s a more modern starting point:
Later on, we’ll be using JavaScript to inject ripples into our HTML as spans with a .ripple class. But before turning to JavaScript, let’s define a style for those ripples in CSS so we have them at the ready:
span.ripple { position: absolute; /* The absolute position we mentioned earlier */ border-radius: 50%; transform: scale(0); animation: ripple 600ms linear; background-color: rgba(255, 255, 255, 0.7); }
To make our ripples circular, we’ve set the border-radius to 50%. And to ensure each ripple emerges from nothing, we’ve set the the default scale to 0. Right now, we won’t be able to see anything because we don’t yet have a value for the top, left, width, or height properties; we’ll soon be injecting these properties with JavaScript.
As for our CSS, the last thing we need to add is an end state for the animation:
Notice that we’re not defining a starting state with the from keyword in the keyframes? We can omit from and CSS will construct the missing values based on those that apply to the animated element. This occurs if the relevant values are stated explicitly — as in transform: scale(0) — or if they’re the default, like opacity: 1.
Now for the JavaScript
Finally, we need JavaScript to dynamically set the position and size of our ripples. The size should be based on the size of the button, while the position should be based on both the position of the button and of the cursor.
We’ll start with an empty function that takes a click event as its argument:
function createRipple(event) { // }
We’ll access our button by finding the currentTarget of the event.
const button = event.currentTarget;
Next, we’ll instantiate our span element, and calculate its diameter and radius based on the width and height of the button.
Before adding our span element to the DOM, it’s good practice to check for any existing ripples that might be leftover from previous clicks, and remove them before executing the next one.
const ripple = button.getElementsByClassName("ripple")[0]; if (ripple) { ripple.remove(); }
As a final step, we append the span as a child to the button element so it is injected inside the button.
button.appendChild(circle);
With our function complete, all that’s left is to call it. This could be done in a number of ways. If we want to add the ripple to every button on our page, we can use something like this:
const buttons = document.getElementsByTagName("button"); for (const button of buttons) { button.addEventListener("click", createRipple); }
We now have a working ripple effect!
Taking it further
What if we want to go further and combine this effect with other changes to our button’s position or size? The ability to customize is, after all, one of the main advantages we have by choosing to recreate the effect ourselves. To test how easy it is to extend our function, I decided to add a “magnet” effect, which causes our button to move towards our cursor when the cursor’s within a certain area.
We need to rely on some of the same variables defined in the ripple function. Rather than repeating code unnecessarily, we should store them somewhere they’re accessible to both methods. But we should also keep the shared variables scoped to each individual button. One way to achieve this is by using classes, as in the example below:
Since the magnet effect needs to keep track of the cursor every time it moves, we no longer need to calculate the cursor position to create a ripple. Instead, we can rely on cursorX and cursorY.
Two important new variables are magneticPullX and magneticPullY. They control how strongly our magnet method pulls the button after the cursor. So, when we define the center of our ripple, we need to adjust for both the position of the new button (x and y) and the magnetic pull.
To apply these combined effects to all our buttons, we need to instantiate a new instance of the class for each one:
const buttons = document.getElementsByTagName("button"); for (const button of buttons) { new Button(button); }
Other techniques
Of course, this is only one way to achieve a ripple effect. On CodePen, there are lots of examples that show different implementations. Below are some of my favourites.
CSS-only
If a user has disabled JavaScript, our ripple effect doesn’t have any fallbacks. But it’s possible to get close to the original effect with just CSS, using the :active pseudo-class to respond to clicks. The main limitation is that the ripple can only emerge from one spot — usually the center of the button — rather than responding to the position of our clicks. This example by Ben Szabo is particularly concise:
Pre-ES6 JavaScript
Leandro Parice’s demo is similar to our implementation but it’s compatible with earlier versions of JavaScript:
jQuery
This example use jQuery to achieve the ripple effect. If you already have jQuery as a dependency, it could help save you a few lines of code.
React
Finally, one last example from me. Although it’s possible to use React features like state and refs to help create the ripple effect, these aren’t strictly necessary. The position and size of the ripple both need to be calculated for every click, so there’s no advantage to holding that information in state. Plus, we can access our button element from the click event, so we don’t need refs either.
This React example uses a createRipple function identical to that of this article’s first implementation. The main difference is that — as a method of the Button component — our function is scoped to that component. Also, the onClick event listener is now part of our JSX:
Manuel Matuzović details 10 bad HTML patterns for a close button. You know, stuff like this:
<a class="close" onclick="close()">×</a>
Why is that bad? There is no href there, so it really isn’t a link (close buttons aren’t links). Not to mention the missing href makes this “placeholder link” unfocusable. Plus, that symbol will be read as “multiplication” or “times”, which is not helpful (an “x” isn’t either).
What do you use instead?
There are plenty of good patterns too. If you prefer the visual look of a ×, then…
I was reading Anna Kaley’s “Listboxes vs. Dropdown Lists” post the other day. It’s a fairly straightforward comparison between different UI implementations of selecting options. There is lots of good advice there. Classics like that you should use radio buttons (single select) or checkboxes (multiple select) if you’re showing five or fewer options, and the different options when the number of options grows from there.
One thing that isn’t talked about is how you implement these things. I imagine that’s somewhat on purpose as the point is to talk UX, not tech. But how you implement them plays a huge part in UX. In web design and development circles, the conversation about these things usually involves whether you can pull these things off with native controls, or if you need to rebuild them from scratch. If you can use native controls, you often should, because there are tons of UX that you get for free that that might otherwise be lost or forgotten when you rebuild — like how everything works via the keyboard.
But even without custom styling, we still have some UI options. If you need to select one option from many, we’ve got <input type="radio"> buttons, but data and end-result-wise, that’s the same as a <select>. If you need to select multiple options, we’ve got <input type="checkbox">, but that’s data and end-result-wise the same as <select multiple>.
You pick based on the room you have available and the UX of whatever you’re building.
There is a lot to know about links and buttons in HTML. There is markup implementation and related attributes, styling best practices, things to avoid, and the even-more-nuanced cousins of the link: buttons and button-like inputs.
Let’s take a look at the whole world of links and buttons, and all the considerations at the HTML, CSS, JavaScript, design, and accessibility layers that come with them. There are plenty of pitfalls and bad practices to avoid along the way. By covering it, we’ll have a complete good UX implementation of both elements.
Quick rules of thumb on when to use each:
Are you giving a user a way to go to another page or a different part of the same page? Use a link (<a href="/somewhere">link</a>)
Are you making a JavaScript-powered clickable action? Use a button (<button type="button">button</button>)
Are you submitting a form? Use a submit input (<input type="submit" value="Submit">)
Links
Links are one of the most basic, yet deeply fundamental and foundational building blocks of the web. Click a link, and you move to another page or are moved to another place within the same page.
That’s a link to a “fully qualified” or “absolute” URL.
A relative link
You can link “relatively” as well:
<!-- Useful in navigation, but be careful in content that may travel elsewhere (e.g. RSS) --> <a href="/pages/about.html">About</a>
That can be useful, for example, in development where the domain name is likely to be different than the production site, but you still want to be able to click links. Relative URLs are most useful for things like navigation, but be careful of using them within content — like blog posts — where that content may be read off-site, like in an app or RSS feed.
A jump link
Links can also be “hash links” or “jump links” by starting with a #:
<a href="#section-2">Section Two</a> <!-- will jump to... --> <section id="section-2"></section>
Clicking that link will “jump” (scroll) to the first element in the DOM with an ID that matches, like the section element above.
💥 Little trick: Using a hash link (e.g. #0) in development can be useful so you can click the link without being sent back to the top of the page like a click on a # link does. But careful, links that don’t link anywhere should never make it to production.
💥 Little trick: Jump-links can sometimes benefit from smooth scrolling to help people understand that the page is moving from one place to another.
It’s a fairly common UI/UX thing to see a “Back to top” link on sites, particularly where important navigational controls are at the top but there is quite a bit of content to scroll (or otherwise navigate) through. To create a jump link, link to the ID of an element that is at the top of the page where it makes sense to send focus back to.
<a href="#top-of-page">Back to Top</a>
Jump links are sometimes also used to link to other anchor (<a>) elements that have no href attribute. Those are called “placeholder” links:
A link without an href attribute is the only practical way to disable a link. Why disable a link? Perhaps it’s a link that only becomes active after logging in or signing up.
a:not[href] { /* style a "disabled" link */ }
When a link has no href, it has no role, no focusability, and no keyboard events. This is intentional.
Do you need the link to open in a new window or tab?
Because you’re trying to beef up your time on site metric.
Because you’re distinguishing between internal and external links or content types.
Because it’s your way out of dealing with infinite scroll trickiness.
Do use it:
Because a user is doing something on the current page, like actively playing media or has unsaved work.
You have some obscure technical reason where you are forced to (even then you’re still probably the rule, not the exception).
Need the link to trigger a download?
The download attribute on a link will instruct the browser to download the linked file rather than opening it within the current page/tab. It’s a nice UX touch.
This attribute is for the relationship of the link to the target.
The rel attribute is also commonly used on the <link> element (which is not used for creating hyperlinks, but for things like including CSS and preloading). We’re not including rel values for the <link> element here, just anchor links.
rel="sponsored": Mark links that are advertisements or paid placements (commonly called paid links) as sponsored.
rel="ugc": For not-particularly-trusted user-generated content, like comments and forum posts.
rel="nofollow": Tell the search engine to ignore this and not associate this site with where this links to.
And also some rel attributes that are most security-focused:
rel="noopener": Prevent a new tab from using the JavaScript window.opener feature, which could potentially access the page containing the link (your site) to perform malicious things, like stealing information or sharing infected code. Using this with target="_blank" is often a good idea.
rel="noreferrer": Prevent other sites or tracking services (e.g. Google Analytics) from identifying your page as the source of clicked link.
You can use multiple space-separated values if you need to (e.g. rel="noopener noreferrer")
rel="directory": Indicates that the destination of the hyperlink is a directory listing containing an entry for the current page.
rel="tag": Indicates that the destination of that hyperlink is an author-designated “tag” (or keyword/subject) for the current page.
rel="payment": Indicates that the destination of that hyperlink provides a way to show or give support for the current page.
rel="help": States that the resource linked to is a help file or FAQ for the current document.
ARIA roles
The default role of a link is link, so you do not need to do:
<a role="link" href="/">Link</a>
You’d only need that if you were faking a link, which would be a weird/rare thing to ever need to do, and you’d have to use some JavaScript in addition to this to make it actually follow the link.
<span class="link" tabindex="0" role="link" data-href="/"> Fake accessible link created using a span </span>
Just looking above you can see how much extra work faking a link is, and that is before you consider that is breaks right-clicking, doesn’t allow opening in a new tab, doesn’t work with Windows High Contrast Mode and other reader modes and assistive technology. Pretty bad!
Probably not. Save this for giving an iframe a short, descriptive title.
<a title="I don't need to be here" href="/"> List of Concerts </a>
title provides a hover-triggered UI popup showing the text you wrote. You can’t style it, and it’s not really that accessible.
Hover-triggered is the key phrase here. It’s unusable on any touch-only device. If a link needs more contextual information, provide that in actual content around the link, or use descriptive text the link itself (as opposed to something like “Click Here”).
That isn’t enough contextual information about the link, particularly for accessibility reasons, but potentially for anybody. Links with text are almost always more clear. If you absolutely can’t use text, you can use a pattern like:
<a href="/"> <!-- Hide the icon from assistive technology --> <svg aria-hidden="true" focusable="false"> ... </svg> <!-- Acts as a label that is hidden from view --> <span class="visually-hidden">Useful link text</span> </a>
visually-hidden is a class used to visually hide the label text with CSS:
Unlike aria-label, visually hidden text can be translated and will hold up better in specialized browsing modes.
Links around images
Images can be links if you wrap them in a link. There is no need to use the alt text to say the image is a link, as assistive technology will do that already.
But it’s slightly weird, so consider the UX of it if you ever do it. For example, it can be harder to select the text, and the entire element needs styling to create clear focus and hover states.
Take this example, where the entire element is wrapped in a link, but there are no hover and focus states applied to it.
If you need a link within that card element, well, you can’t nest links. You could get a little tricky if you needed ot, like using a pseudo-element on the link which is absolutely positioned to cover the whole area.
Additionally, this approach can make really long and potentially confusing announcements for screen readers. Even though links around chunks of content is technically possible, it’s best to avoid doing this if you can.
Styling and CSS considerations
Here’s the default look of a link:
The default User-Agent styling of a link.
It’s likely you’ll be changing the style of your links, and also likely you’ll use CSS to do it. I could make all my links red in CSS by doing:
a { color: red; }
Sometimes selecting and styling all links on a page is a bit heavy-handed, as links in navigation might be treated entirely differently than links within text. You can always scope selectors to target links within particular areas like:
/* Navigation links */ nav a { } /* Links in an article */ article a { } /* Links contained in an element with a "text" class */ .text a { }
Or select the link directly to style.
.link { /* For styling <a class="link" href="/"> */ } a[aria-current="page"] { /* You'll need to apply this attribute yourself, but it's a great pattern to use for active navigation. */ }
Link states
Links are focusable elements. In other words, they can be selected using the Tab key on a keyboard. Links are perhaps the most common element where you’ll very consciously design the different states, including a :focus state.
:hover: For styling when a mouse pointer is over the link.
:visited: For styling when the link has been followed, as best as the browser can remember. It has limited styling ability due to security.
:link: For styling when a link has not been visited.
:active: For styling when the link is pressed (e.g. the mouse button is down or the element is being tapped on a touch screen).
:focus: Very important! Links should always have a focus style. If you choose to remove the default blue outline that most browsers apply, also use this selector to re-apply a visually obvious focus style.
These are chainable like any pseudo-class, so you could do something like this if it is useful for your design/UX.
/* Style focus and hover states in a single ruleset */ a:focus:hover { }
You can style a link to look button-like
Perhaps some of the confusion between links and buttons is stuff like this:
That certainly looks like a button! Everyone would call that a button. Even a design system would likely call that a button and perhaps have a class like .button { }. But! A thing you can click that says “Learn More” is very much a link, not a button. That’s completely fine, it’s just yet another reminder to use the semantically and functionally correct element.
Color contrast
Since we often style links with a distinct color, it’s important to use a color with sufficient color contrast for accessibility. There is a wide variety of visual impairments (see the tool WhoCanUse for simulating color combinations with different impairments) and high contrast helps nearly all of them.
Perhaps you set a blue color for links:
The blue link is #2196F3.
While that might look OK to you, it’s better to use tools for testing to ensure the color has a strong enough ratio according to researched guidelines. Here, I’ll look at Chrome DevTools and it will tell me this color is not compliant in that it doesn’t have enough contrast with the background color behind it.
Chrome DevTools is telling us this link color does not have enough contrast.
Color contrast is a big consideration with links, not just because they are often colored in a unique color that needs to be checked, but because they have all those different states (hover, focus, active, visited) which also might have different colors. Compound that with the fact that text can be selected and you’ve got a lot of places to consider contrast. Here’s an article about all that.
Styling “types” of links
We can get clever in CSS with attribute selectors and figure out what kind of resource a link is pointing to, assuming the href value has useful stuff in it.
/* Style all links that include .pdf at the end */ a[href$ =".pdf"]::after { content: " (PDF)"; } /* Style links that point to Google */ a[href*="google.com"] { color: purple; }
Styling links for print
CSS has an “at-rule” for declaring styles that only take effect on printed media (e.g. printing out a web page). You can include them in any CSS like this:
@media print { /* For links in content, visually display the link */ article a::after { content: " (" attr(href) ")"; } }
Resetting styles
If you needed to take all the styling off a link (or really any other element for that matter), CSS provides a way to remove all the styles using the all property.
.special-area a { all: unset; all: revert; /* Start from scratch */ color: purple; }
You can also remove individual styles with keywords. (Again, this isn’t really unique to links, but is generically useful):
a { /* Grab color from nearest parent that sets it */ color: inherit; /* Wipe out style (turn black) */ color: initial; /* Change back to User Agent style (blue) */ color: revert; }
JavaScript considerations
Say you wanted to stop the clicking of a link from doing what it normally does: go to that link or jump around the page. In JavaScript, you can usepreventDefault to prevent jumping around.
const jumpLinks = document.querySelectorAll("a[href^='#']"); jumpLinks.forEach(link => { link.addEventListener('click', event => { event.preventDefault(); // Do something else instead, like handle the navigation behavior yourself }); });
This kind of thing is at the core of how “Single Page Apps” (SPAs) work. They intercept the clicks so browsers don’t take over and handle the navigation.
SPAs see where you are trying to go (within your own site), load the data they need, replace what they need to on the page, and update the URL. It’s an awful lot of work to replicate what the browser does for free, but you get the ability to do things like animate between pages.
Another JavaScript concern with links is that, when a link to another page is clicked, the page is left and another page loads. That can be problematic for something like a page that contains a form the user is filling out but hasn’t completed. If they click the link and leave the page, they lose their work! Your only opportunity to prevent the user from leaving is by using the beforeunload event.
window.addEventListener("beforeunload", function(event) { // Remind user to save their work or whatever. });
A link that has had its default behavior removed won’t announce the new destination. This means a person using assistive technology may not know where they wound up. You’ll have to do things like update the page’s title and move focus back up to the top of the document.
JavaScript frameworks
In a JavaScript framework, like React, you might sometimes see links created from something like a <Link /> component rather than a native <a> element. The custom component probably creates a native <a> element, but with extra functionality, like enabling the JavaScript router to work, and adding attributes like aria-current="page" as needed, which is a good thing!
Ultimately, a link is a link. A JavaScript framework might offer or encourage some level of abstraction, but you’re always free to use regular links.
Accessibility considerations
We covered some accessibility in the sections above (it’s all related!), but here are some more things to think about.
You don’t need text like “Link” or “Go to” in the link text itself. Make the text meaningful (“documentation” instead of “click here”).
Links already have an ARIA role by default (role="link") so there’s no need to explicitly set it.
Try not to use the URL itself as the text (<a href="google.com">google.com</a>)
Links are generally blue and generally underlined and that’s generally good.
All images in content should have alt text anyway, but doubly so when the image is wrapped in a link with otherwise no text.
Unique accessible names
Some assistive technology can create lists of interactive elements on the page. Imagine a group of four article cards that all have a “Read More”, the list of interactive elements will be like:
Read More
Read More
Read More
Read More
Not very useful. You could make use of that .visually-hidden class we covered to make the links more like:
<a href="/article"> Read More <span class="visually-hidden"> of the article "Dancing with Rabbits". <span> </a>
Now each link is unique and clear. If the design can support it, do it without the visually hidden class to remove the ambiguity for everyone.
Buttons
Buttons are for triggering actions. When do you use the <button> element? A good rule is to use a button when there is “no meaningful href.” Here’s another way to think of that: if clicking it doesn’t do anything without JavaScript, it should be a <button>.
A <button> that is within a <form>, by default, will submit that form. But aside from that, button elements don’t have any default behavior, and you’ll be wiring up that interactivity with JavaScript.
Buttons inside of a <form> do something by default: they submit the form! They can also reset it, like their input counterparts. The type attributes matter:
<form action="/" method="POST"> <input type="text" name="name" id="name"> <button>Submit</button> <!-- If you want to be more explicit... --> <button type="submit">Submit</button> <!-- ...or clear the form inputs back to their initial values --> <button type="reset">Reset</button> <!-- This prevents a `submit` action from firing which may be useful sometimes inside a form --> <button type="button">Non-submitting button</button> </form>
Speaking of forms, buttons have some neat tricks up their sleeve where they can override attributes of the <form> itself.
<form action="/" method="get"> <!-- override the action --> <button formaction="/elsewhere/" type="submit">Submit to elsewhere</button> <!-- override encytype --> <button formenctype="multipart/form-data" type="submit"></button> <!-- override method --> <button formmethod="post" type="submit"></button> <!-- do not validate fields --> <button formnovalidate type="submit"></button> <!-- override target e.g. open in new tab --> <button formtarget="_blank" type="submit"></button> </form>
Autofocus
Since buttons are focusable elements, we can automatically focus on them when the page loads using the autofocus attribute:
Perhaps you’d do that inside of a modal dialog where one of the actions is a default action and it helps the UX (e.g. you can press Enter to dismiss the modal). Autofocusing after a user action is perhaps the only good practice here, moving a user’s focus without their permission, as the autofocus attribute is capable of, can be a problem for screen reader and screen magnifier users.
Note thatautofocus may not work if the element is within an <iframe sandbox> for security reasons.
Disabling buttons
To prevent a button from being interactive, there is a disabled attribute you can use:
<button disabled>Pay Now</button> <p class="error-message">Correct the form above to submit payment.</p>
Note that we’ve included descriptive text alongside the disabled button. It can be very frustrating to find a disabled button and not know why it’s disabled. A better way to do this could be to let someone submit the form, and then explain why it didn’t work in the validation feedback messaging.
Regardless, you could style a disabled button this way:
/* Might be good styles for ANY disabled element! */ button[disabled] { opacity: 0.5; pointer-events: none; }
We’ll cover other states and styling later in this guide.
Buttons can contain child elements
A submit button and a submit input (<input type="submit">) are identical in functionality, but different in the sense that an input is unable to contain child elements while a button can.
Note the focusable="false" attribute on the SVG element above. In that case, since the icon is decorative, this will help assistive technology only announce the button’s label.
Styling and CSS considerations
Buttons are generally styled to look very button-like. They should look pressable. If you’re looking for inspiration on fancy button styles, you’d do well looking at the CodePen Topic on Buttons.
How buttons look by default varies by browser and platform.
Just on macOS: Chrome, Safari, and Firefox (they look the same)
Add border: 0; to those same buttons as above, and we have different styles entirely.
While there is some UX truth to leaving the defaults of form elements alone so that they match that browser/platform’s style and you get some affordance for free, designers typically don’t like default styles, particularly ones that differ across browsers.
Resetting the default button style
Removing all the styles from a button is easier than you think. You’d think, as a form control, appearance: none; would help, but don’t count on that. Actually all: revert; is a better bet to wipe the slate clean.
You can see how a variety of properties are involved
And that’s not all of them. Here’s a consolidated chunk of what Normalize does to buttons.
button { font-family: inherit; /* For all browsers */ font-size: 100%; /* For all browsers */ line-height: 1.15; /* For all browsers */ margin: 0; /* Firefox and Safari have margin */ overflow: visible; /* Edge hides overflow */ text-transform: none; /* Firefox inherits text-transform */ -webkit-appearance: button; /* Safari otherwise prevents some styles */ } button::-moz-focus-inner { border-style: none; padding: 0; } button:-moz-focusring { outline: 1px dotted ButtonText; }
A consistent .button class
In addition to using reset or baseline CSS, you may want to have a class for buttons that gives you a strong foundation for styling and works across both links and buttons.
There are always exceptions. For example, a website in which you need a button-triggered action within a sentence:
<p>You may open your <button>user settings</button> to change this.</p>
We’ve used a button instead of an anchor tag in the above code, as this hypothetical website opens user settings in a modal dialog rather than linking to another page. In this situation, you may want to style the button as if it looks like a link.
This is probably rare enough that you would probably make a class (e.g. .link-looking-button) that incorporates the reset styles from above and otherwise matches what you do for anchor links.
Breakout buttons
Remember earlier when we talked about the possibility of wrapping entire elements in links? If you have a button within another element, but you want that entire outer element to be clickable/tappable as if it’s the button, that’s a “breakout” button. You can use an absolutely-positioned pseudo-element on the button to expand the clickable area to the whole region. Fancy!
JavaScript considerations
Even without JavaScript, button elements can be triggered by the Space and Enter keys on a keyboard. That’s part of what makes them such appealing and useful elements: they are discoverable, focusable, and interactive with assistive technology in a predictable way.
Perhaps any <button> in that situation should be inserted into the DOM by JavaScript. A tall order! Food for thought. 🤔
“Once” handlers
Say a button does something pretty darn important, like submitting a payment. It would be pretty scary if it was programmed such that clicking the button multiple times submitted multiple payment requests. It is situations like this where you would attach a click handler to a button that only runs once. To make that clear to the user, we’ll disable the button on click as well.
That practice went from being standard practice to being a faux pas (not abstracting JavaScript functionality away from HTML) to, eh, you need it when you need it. One advantage is that if you’re injecting this HTML into the DOM, you don’t need to bind/re-bind JavaScript event handlers to it because it already has one.
JavaScript frameworks
It’s common in any JavaScript framework to make a component for handling buttons, as buttons typically have lots of variations. Those variations can be turned into an API of sorts. For example, in React:
In that example, the <Button /> component ensures the button will have a button class and handles a toggle-like active class.
Accessibility considerations
The biggest accessibility consideration with buttons is actually using buttons. Don’t try to replicate a button with a <div> or a <span>, which is, unfortunately, more common than you might think. It’s very likely that will cause problems. (Did you deal with focusability? Did you deal with keyboard events? Great. There’s still probably more stuff you’re forgetting.)
Focus styles
Like all focusable elements, browsers apply a default focus style, which is usually a blue outline.
Focus styles on Chrome/macOS
While it’s arguable that you should leave that alone as it’s a very clear and obvious style for people that benefit from focus styles, it’s also not out of the question to change it.
What you should not do is button:focus { outline: 0; } to remove it. If you ever remove a focus style like that, put it back at the same time.
button:focus { outline: 0; /* Removes the default blue ring */ /* Now, let's create our own focus style */ border-radius: 3px; box-shadow: 0 0 0 2px red; }
Custom focus style
The fact that a button may become focused when clicked and apply that style at the same time is offputting to some. There is a trick (that has limited, but increasing, browser support) on removing focus styles from clicks and not keyboard events:
:focus:not(:focus-visible) { outline: 0; }
ARIA
Buttons already have the role they need (role="button"). But there are some other ARIA attributes that are related to buttons:
aria-pressed: Turns a button into a toggle, between aria-pressed="true" and aria-pressed="false". More on button toggles, which can also be done with role="switch" and aria-checked="true".
aria-expanded: If the button controls the open/closed state of another element (like a dropdown menu), you apply this attribute to indicate that like aria-expanded="true".
aria-label: Overrides the text within the button. This is useful for labeling buttons that otherwise don’t have text, but you’re still probably better off using a visually-hidden class so it can be translated.
aria-labelledby: Points to an element that will act as the label for the button.
For that last one:
<button aria-labelledby="buttonText"> Time is running out! <span id="buttonText">Add to Cart</span> </button>
If a button opens a dialog, your job is to move the focus inside and trap it there. When closing the dialog, you need to return focus back to that button so the user is back exactly where they started. This makes the experience of using a modal the same for someone who relies on assistive technology as for someone who doesn’t.
Focus management isn’t just for dialogs, either. If clicking a button runs a calculation and changes a value on the page, there is no context change there, meaning focus should remain on the button. If the button does something like “move to next page,” the focus should be moved to the start of that next page.
Size
Don’t make buttons too small. That goes for links and any sort of interactive control. People with any sort of reduced dexterity will benefit.
In addition to ample size, don’t place buttons too close each other, whether they’re stacked vertically or together on the same line. Give them some margin because people experiencing motor control issues run the risk of clicking the wrong one.
Activating buttons
Buttons work by being clicked/touched, pressing the Enter key, or pressing the Space key (when focused). Even if you add role="button" to a link or div, you won’t get the spacebar functionality, so at the risk of beating a dead horse, use <button> in those cases.
In this oldie but goodie, Hampus Sethfors digs into why disabled buttons are troubling for usability reasons and he details one example where this was pretty annoying for him. The same has happened to me recently where I clicked a button that looked like a secondary button and… nothing happened.
Here’s another reason why disabled buttons are bad though:
Disabled buttons usually have call to action words on them, like “Send”, “Order” or “Add friend”. Because of that, they often match what users want to do. So people will try to click them.
What’s the alternative? Throw errors? Highlight the precise field that the user needs to update? All those are certainly very helpful to show what the problem is and how to fix it. To play devil’s advocate for a second though, Daniel Koster wrote about how disabled buttons don’t have to suck which is sort of interesting.
But whatever the case, let’s just not get into the topic of disabled links.
Andy covers a technique where a semantic <button> is used within a card component, but really, the whole card is clickable. The trick is to put a pseudo-element that goes beyond the button, covering the entire card. The tradeoff is that the pseudo-element sits on top of the text, so text selection is hampered a bit. I believe this is better than making the whole dang area a <button> because that would sacrifice semantics and likely cause extreme weirdness for assistive technology.
You could do the same thing if your situation requires an <a> link instead of a <button>, but if that’s the case, you actually can wrap the whole area in the link without much grief then wrap the part that appears to be a button in a span or something to make it look like a button.
This reminds me of the nested link problem: a large linked block that contains other different linked areas in it. Definitely can’t nest anchor links. Sara Soueidan had the best answer where the “covering” link is placed within the card and absolutely positioned to cover the area while other links inside could be be layered on top with z-index.