This episode covers pseudo-classes and pseudo-elements: interaction states like hover, focus, and focus-visible, structural selection like nth-child and not, and the decorative ::before and ::after elements with the content property.

So far you've selected elements by name, class, or id. But many elements we want to touch can't be told apart from their HTML attributes alone — the element being hovered, odd-order items, or decorative parts before text. That's where pseudo-classes and pseudo-elements come in. Pseudo-classes select elements based on state or position. Pseudo-elements target parts of an element, including parts that don't exist in the document like a line before the text. Why does it matter? Both bring interactions to life — hover feedback, keyboard focus, zebra-striped rows — and avoid bloating the HTML with empty spans for decoration.
States like :hover, :focus, and :active give feedback on interactions:
.tombol {
background-color: #2563eb;
color: white;
transition: background-color 0.2s ease;
}
.tombol:hover {
background-color: #1d4ed8;
}
.tombol:focus {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}
.tombol:active {
background-color: #1e40af;
}:hover responds to the cursor over the element, :active when pressed, and :focus when the element receives focus — from either a click or Tab. Focus is the accessibility bridge: without a visible focus style, keyboard users lose the indicator of where they are.
.tombol:focus-visible {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}:focus-visible only applies styles when focus comes from the keyboard, not from a mouse click. The best pattern: show the outline only for :focus-visible, avoiding distracting focus rings on click.
Structural pseudo-classes select elements by their position among siblings:
li:first-child {
font-weight: bold;
}
li:last-child {
border-bottom: none;
}
tr:nth-child(even) {
background-color: #f1f5f9;
}
li:not(.disembunyikan) {
display: block;
}:first-child, :last-child, and :nth-child select by index. :nth-child(odd) and :nth-child(even) make zebra-striping tables easy. :not() excludes elements matching the selector inside it.
:is(header, footer) a {
text-decoration: none;
}
:where(.kartu, .panel) {
border-radius: 8px;
}:is() and :where() accept a list of selectors and remove repetition. Both behave the same, except for specificity: :is() takes the highest specificity of its members, while :where() is always zero — making it easy to override.
::before and ::after insert decorative content at the beginning and end of an element. They only exist when the content property is defined:
.link-unduh::before {
content: "↓ ";
}
.link-unduh::after {
content: " (PDF)";
font-size: 0.8em;
}
.quote::before {
content: open-quote;
}
.quote::after {
content: close-quote;
}These pseudo-elements are great for decorative icons, quotation marks, small labels, and separators — without adding HTML nodes. Text generated by content isn't consistently read by screen readers, so don't put important information inside it.
.badge::before {
content: "";
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #22c55e;
margin-right: 6px;
}To draw geometric shapes, give content: "" and then style it like a normal element. The small circle above produces an "online" indicator without any extra markup.
.list { list-style: none; padding: 0; margin: 0; font-family: system-ui, sans-serif; }
.list li { padding: 12px 16px; border-bottom: 1px solid #e2e8f0; }
.list li:hover { background-color: #f1f5f9; }
.list li:last-child { border-bottom: none; }
.list li:nth-child(even) { background-color: #f8fafc; }
.list li:hover:nth-child(even) { background-color: #f1f5f9; }
.list a { color: #2563eb; text-decoration: none; }
.list a::after { content: " →"; }Even rows get a soft color, the first and last rows are tidied, and links end with an arrow from ::after. All these effects are achieved without a single extra span in the HTML.
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Daftar Interaktif</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<ul class="list">
<li><a href="#">Beranda</a></li>
<li><a href="#">Artikel</a></li>
<li><a href="#">Proyek</a></li>
<li><a href="#">Kontak</a></li>
</ul>
</body>
</html>Run npx serve ., then hover over each row and press Tab to see the focus. The combination of :hover, :nth-child, and ::after produces an interface that feels alive with pure CSS.
If ::before or ::after isn't visible, check whether content is defined. Without content, the pseudo-element isn't created at all.
Text inside content isn't always read by screen readers. Keep important information in the HTML, and use pseudo-elements for decoration only.
Don't remove the focus outline without a replacement. If you remove the default, provide a clear :focus-visible so keyboard navigation stays guided.
Key takeaways:
:hover, :focus, and :active.:focus-visible limits focus styles to keyboard interactions.:nth-child, :first-child, and :not() select based on structure.::before and ::after insert decorative content via content.:is() and :where() reduce selector repetition.content.
In the next episode, episode 14, we'll cover CSS transitions and animations — smooth movement with transition and full animation sequences with @keyframes.