There's a label element, and inside it a checkbox. The nice thing about this is that the browser is already doing things for us. Because the input is inside the label, the browser has associated them and now we can click anywhere on the label to toggle the checkbox, no onclick handler in sight. The browser gives us this for free. Feature-wise, we're done.
Of course, designers may not like the way this looks and we want to create a great looking custom switch. So let's add a bunch of CSS:
input {
appearance: none;
position: relative;
display: inline-block;
background: lightgrey;
height: 1.65rem;
width: 2.75rem;
vertical-align: middle;
border-radius: 2rem;
box-shadow: 0px 1px 3px #0003 inset;
transition: 0.25s linear background;
}
input::before {
content: "";
display: block;
width: 1.25rem;
height: 1.25rem;
background: #fff;
border-radius: 1.2rem;
position: absolute;
top: 0.2rem;
left: 0.2rem;
box-shadow: 0px 1px 3px #0003;
transition: 0.25s linear transform;
transform: translateX(0rem);
}
All the specifics of the styling here don't matter as much, but I want you to take a look at that first rule: appearance: none
.
Form elements, along with images, are something called "replaced content". That means they're not really part of your HTML, but supplied by the browser. When the browser renders your HTML and finds replaced content, it leaves a box for it, and then replaces that box with the actual content. This is why, for example, images and form elements can't have pseudo-elements: they get replaced when the browser replaces the entire element.
appearance
is a way of telling the browser to stop doing that. It tells the browser: "Thanks, but I want to style my own form control". And that then allows is to use the ::before
pseudo-element. The input itself is now the background of our switch, and the ::before
pseudo-element is the little dot inside of it that does the toggling.
Clicking this still checks and unchecks the checkbox, but because we replaced the element we need to do the work of making that visible ourselves. That's where the :checked
pseudo-class comes in:
:checked {
background: green;
}
:checked::before {
transform: translateX(1rem);
}
When you click the checkbox, that :checked
pseudo-class starts to match and that causes the styling to update.
So we have a great looking custom switch using native HTML elements and a bit of CSS, but we're not done yet. While for mouse users it's really clear which form control they're interacting with (since they're pointing at it and clicking), for people using a keyboard that's not so easy.
I'm sure you're familiar with this bit of CSS. To get rid of that ugly, dotted, boxy outline.
input:focus {
outline: none;
}
If you're reading this, know that's not a good idea. But how do we make it look, well, nicer? Here too browsers have updated to make things better for us. The outline
now follows the border-radius of an element, and we can also offset it away, or inside of, the element:
input:focus-visible {
outline: 2px solid dodgerblue;
outline-offset: 2px;
}
Now, when a user interacts with an element using the keyboard (you can try pressing the spacebar after clicking it, or tabbing to it), :focus-visible
will match (it won't when using a mouse) and they get a good looking, blue outline slightly around the element.
Lastly, I want you to replace that outline: none
with something else:
input:focus {
outline-color: transparent;
}
This will have the same result: Instead of the outline not being visible because it's hidden, it's not visible because it's transparent. For users that have high contrast mode (also called forced colors) turned on however, that outline becomes visible again because in high contrast mode, that transparent color gets replaced with a color the user chose, helping them see what they're interacting with even if they use a mouse.
This article isn't long enough to also go into what forced-colors does but if you want to learn more check out my article forced colors explained.
Instead of installing $your-framework-autosuggest
, try out datalist in your next project. Datalist is the browsers built-in way of showing a list of options as a user types into an input.
<input list="frameworks" />
<datalist id="frameworks">
<option>Bootstrap</option>
<option>Tailwind CSS</option>
<option>Foundation</option>
<option>Bulma</option>
<option>Skeleton</option>
</datalist>
To use it, you add a datalist element with an ID and a set of options to your HTML. Don't worry, the element won't be visible. Then you use the list
attribute on an input to associate the two.
As a user now types into the input, the browser will show the datalist as a dropdown, automatically filtering the options as the user types. Because it's a regular input though, users still have the option to type in their own value. Lastly, they can see all of the options by selecting the input and using the arrow keys to navigate the list, or clicking the dropdown icon the browser adds.
There are a ton of good looking color pickers out there, with nice canvas UIs and sliders that are built with 100s of lines of JavaScript. But did you know that you can also use a native color picker?
<label> <input type="color" /> Color </label>
This single line of HTML also give you a color picker with a nice UI, already saving you a bunch of JS. But because we're letting the browser handle it, we actually get more functionality for free. In Chromium browsers, that native color picker also lets you pick a color, not just from your own site but from anywhere on the screen. Pretty neat!
A quick note here is that even though browsers show a nice color picker, your users might not all be able to use it. So offering a different way of picking a color (like a regular text input) is still a good idea.
Accordions are a great way of making a page with a lot of content more structured and uncluttered by keeping content out of the way until a user needs it. And browsers give them to you for free with the details
and summary
elements:
<details>
<summary>My accordion</summary>
<p>My accordion content</p>
</details>
My accordion content
By default everything inside a details
element is hidden except for the summary
. Then when a user clicks the summary
element the browser will show the rest of the content.
What you'll often see is that one of the accordion items is already open, and the rest are closed. You can do that with the open
attribute:
<details open>
<summary>My accordion</summary>
<p>My accordion content</p>
</details>
My accordion content
If you come from the React world, you might look at this code and think “Well that's great, now it has the open
prop and isn't going to close anymore“ but luckily, that's not the case. That open
attribute is only the starting state, and will update when a user interacts with the accordion.
When it comes to styling, the details
element also has you covered. That little triangle (that your designer will want to replace the instant they see it) is a ::marker
pseudo-element that you can style:
summary::marker {
font-size: 1.5em;
content: "📬";
}
[open] summary::marker {
font-size: 1.5em;
content: "📭";
}
My accordion content
Keep in mind that changing the content can affect how assistive technologies announce your accordion. Read Manuels article on that here: details/summary inconsistencies. In addition, for Safari you'll have to use the ::-webkit-details-marker
pseudo-element.
The marker pseudo-element can't be styled as extensively as other elements (many CSS properties do not work on it, like positioning it in a completely different place), but you can replace its content, for example with emoji, or set a background color or image and change it's font size.
With the open
attribute you can easily give it different styling from the closed state.
Lastly, we want to do something about that summary element. It's clickable, but unlike a link it doesn't get a pointer cursor, and unlike a button it, well, doesn't look like a button. So I think we should add a hover and focus state to it and help our visitors realise that it's clickable:
summary:hover,
summary:focus {
cursor: pointer;
background: deeppink;
}
My accordion content
I'm sidestepping the “only links should have pointer cursors” discussion here, the main point I'm making is that you need to do something.
Sometimes you need to inform the user about something, or ask them something or get them to confirm something. In JavaScript, that's what alert()
, prompt()
and confirm()
do. But they have a pretty big downside: they lock the main thread, meaning your page can't do anything else. They're also browser-native, so you can't style them to work with your design.
Building your own dialog is also asking for trouble: you need to keep the focus inside the dialog for accessibility, announce it's modal-ness, make sure users can't exit it accidentally, and you'll have to fight with whatever chat widget occupied the z-index of 2147483647 (if you know you know).
So that's why browsers now come with a native dialog element:
<dialog>
<form method="dialog">
<h3>This is a pretty dialog</h3>
<button type="submit">Close</button>
</form>
</dialog>
This element isn't shown by default and, for now, this is where I'm going to cheat a little and use JavaScript:
document.querySelector("button").addEventListener("click", () => {
document.querySelector("dialog").showModal();
});
Now there's changes in the works that will let you open dialogs without JavaScript, but they're not fully specced yet, let alone implemented. So for now, we need to use JavaScript to open the dialog. But that's it, the rest is all native HTML and CSS.
The dialog element has a showModal()
function that it exposes and with it, you open the dialog. This dialog is opened on something called the top-layer
, which is a new concept in browsers. For a primer, check out the explainer on MDN: Top layer.
The top layer is a new layer that's separate from your HTML, and you can "promote" elements to it. That means that elements on the top layer will always be above everything else, regardless of the z-index of an element and stacking context nesting.
Now that it's open though, you might notice that the browser doesn't give you any UI. The dialog is pretty much a div (not a button!) and it's up to you to provide the UI for closing. That's what the form in the code above does. You might've noticed it has a method of "dialog". When this form gets submitted, the browser takes that as a signal to close the dialog again.
With that, you can also create confirmation dialogs by providing two buttons, each with each own values:
<dialog>
<form method="dialog">
<p>Tabs or spaces?</p>
<button type="submit" value="wrong">Tabs</button>
<button type="submit" value="correct">Spaces</button>
</form>
</dialog>
The button that a user clicked can be found by listening to the close
event on the dialog and reading it's 'returnValue' property:
dialog.addEventListener("close", function () {
console.log(dialog.returnValue);
});
If you have any other form data in there you can also read that as formData
.
Because the dialog is essentially a div as far as styling it concerned, you can style it however you want. The browser will automatically place it in the middle of the screen for you, but everything else is up to you.
Dialog also comes with a new pseudo-element called ::backdrop
. That's the layer that sits between the dialog and the rest of the page, and you can style it to e.g. dim the rest of the page or otherwise direct a users attention to the dialog. For example, you can overlay a white layer and blur the page:
dialog::backdrop {
background: #fff5;
backdrop-filter: blur(4px);
}
Just like the dialog element itself, the backdrop is positioned by the browser, so you won't need to worry about scrolling, fixed elements and browser resizing. It's all handled for you by the browser.
I hope you found a few things in this article that made you realise you can use a little bit less javascript in your next project. Whenever you change a known battle-tested implementation to something new it's good to test it, especially when it comes to accessibility, to make sure that you're not excluding anyone.
There are dozens more examples I could've added into this article, here are just some you can look into:
scroll-behavior: smooth
(but only when prefers-reduced-motion: no preference
matches!),position: sticky
And if we look into the future, we're getting even more cool things:
grid-template-rows: masonry
select
with the new selectlist
element (where you can style each part of a select without destroying all the native functionality it comes with):has()
selector that's going to eliminate a whole class of JS selectionThis article is an adaption of a conference talk I gave that goes into more detail on these and other topics, and you can watch it here: Stop Using JavaScript for That: Moving Features from JS to CSS and HTML.
So let me re-iterate the main point of this article:
Just because you know something needs JavaScript, doesn't mean it still does. You can make better websites if you test those assumptions every now and then.
Web developer and creator of Polypane.app, the browser for developers.
Blog: kilianvalkhof.com
Mastodon: @kilian
X: @kilianvalkhof
Polypane: Polypane.app