- Published on
Advanced CSS Pseudo-Selectors Explained by Building a Financial Balance Sheet
- Authors
- Name
- Md Nasim Sheikh
- @nasimStg
'Advanced CSS Pseudo-Selectors Explained by Building a Financial Balance Sheet'
Unlock the power of modern CSS by learning advanced pseudo-selectors. This practical guide walks you through building a responsive, beautifully styled balance sheet, step-by-step.
Table of Contents
- 'Advanced CSS Pseudo-Selectors Explained by Building a Financial Balance Sheet'
- Setting the Stage: The HTML Foundation
- Section 1: The :nth-child Family - Mastering Structure
- Zebra-Striping with :nth-child()
- The Power of :nth-of-type()
- Section 2: The Game Changer - Relational Pseudo-class :has()
- Styling Based on Content
- Styling Form Elements Based on State
- Section 3: Logical Pseudo-classes - :is(), :where(), and :not()
- :is() - Reducing Repetition
- :where() - The Zero-Specificity Wonder
- :not() - The Exclusion Principle
- Section 4: Pseudo-Elements - Styling the Unseen
- ::before and ::after - Adding Content
- ::marker - Styling List Bullets
- ::selection - Customizing Text Selection
- Putting It All Together: The Final CSS
- Conclusion: CSS is More Powerful Than You Think
When we think of CSS, we often think of colors, fonts, and layouts. But modern CSS is a powerful language capable of expressing complex logic and relationships directly in the stylesheet, often eliminating the need for JavaScript. The secret? A deep understanding of its selectors, especially the advanced pseudo-selectors.
Today, we're going to move beyond .class
and #id
. We'll explore the advanced pseudo-selectors that allow you to style elements based on their position, their siblings, their children, and even their state. And we won't be building a simple button or card. We're going to tackle a real-world component: a financial balance sheet. Its structured, hierarchical data is the perfect canvas to showcase what modern CSS can really do.
By the end of this post, you'll not only have a beautifully styled, dynamic balance sheet but also a powerful new set of tools in your CSS arsenal.
Setting the Stage: The HTML Foundation
First things first, we need a solid HTML structure. A balance sheet is fundamentally tabular data, so a <table>
is the most semantic and accessible choice. Our table will include a <caption>
, a <thead>
, a <tfoot>
, and a <tbody>
containing various types of rows for assets, liabilities, headers, and totals.
Here's the HTML we'll be working with. Don't worry about the CSS just yet; we'll build it up piece by piece.
<table class="balance-sheet">
<caption>Balance Sheet - Acme Corp. - As of December 31, 2023</caption>
<thead>
<tr>
<th>Account</th>
<th class="amount">Amount</th>
</tr>
</thead>
<tbody>
<tr class="section-header">
<td colspan="2">Assets</td>
</tr>
<!-- Asset Items -->
<tr class="data-row asset">
<td>Cash and Equivalents</td>
<td class="amount">$50,000.00</td>
</tr>
<tr class="data-row asset">
<td>Accounts Receivable</td>
<td class="amount">$25,000.00</td>
</tr>
<tr class="data-row asset">
<td>Inventory</td>
<td class="amount">$30,000.00</td>
</tr>
<tr class="sub-total">
<td>Total Current Assets</td>
<td class="amount">$105,000.00</td>
</tr>
<!-- A non-data row to demonstrate selector differences -->
<tr class="note-row">
<td colspan="2"><em>Note: Inventory valued at historical cost.</em></td>
</tr>
<tr class="data-row asset">
<td>Property, Plant, and Equipment</td>
<td class="amount">$150,000.00</td>
</tr>
<tr class="section-header">
<td colspan="2">Liabilities & Equity</td>
</tr>
<!-- Liability Items -->
<tr class="data-row liability">
<td>Accounts Payable</td>
<td class="amount">($20,000.00)</td>
</tr>
<tr class="data-row liability">
<td>Short-term Debt</td>
<td class="amount">($15,000.00)</td>
</tr>
<tr class="sub-total">
<td>Total Liabilities</td>
<td class="amount">($35,000.00)</td>
</tr>
<!-- Equity Items -->
<tr class="data-row equity">
<td>Shareholder Equity</td>
<td class="amount">$220,000.00</td>
</tr>
</tbody>
<tfoot>
<tr class="grand-total">
<td>Total Assets</td>
<td class="amount">$255,000.00</td>
</tr>
<tr class="grand-total">
<td>Total Liabilities & Equity</td>
<td class="amount">$255,000.00</td>
</tr>
<tr class="verification">
<td>Assets = Liabilities + Equity</td>
<td class="amount status-ok">✔ Balanced</td>
</tr>
</tfoot>
</table>
With some basic styling for fonts and spacing, it looks clean, but it's static and a bit boring. Let's bring it to life with advanced selectors.
:nth-child
Family - Mastering Structure
Section 1: The Structural pseudo-classes select elements based on their position in the document tree. The :nth-child
family is the most powerful and versatile group.
:nth-child()
Zebra-Striping with The classic use case is creating "zebra stripes" for better readability in long tables. We want to style every other data row. A common mistake is to apply tr:nth-child(even)
. Let's see why that's a problem here.
/* This is NOT quite right */
.balance-sheet tbody tr:nth-child(even) {
background-color: #f8f9fa;
}
If you apply this, you'll notice that our .section-header
and .note-row
throw off the counting, resulting in an inconsistent pattern. We only want to count and style the .data-row
elements.
:nth-of-type()
The Power of This is where :nth-of-type(n)
shines. It works like :nth-child(n)
, but it only considers elements of the same type (e.g., only p
tags, or in our case, only tr
elements with a specific class). To use it effectively, we need to target a subset of rows.
Unfortunately, nth-of-type
can't filter by class. It only looks at the element's tag name. So, tr.data-row:nth-of-type(even)
won't work as expected because it still counts all tr
elements.
So how do we solve this? We can't do it with :nth-child
or :nth-of-type
alone. This is a classic CSS challenge that, until recently, required JavaScript. But hold that thought... we'll solve this exact problem with a newer selector in a moment.
For now, let's use :nth-child
where it does work perfectly. Let's say we want to style the first and last data rows within each logical section. We can use the adjacent sibling selector +
and the general sibling combinator ~
.
/* Style the first data row after a section header */
.section-header + .data-row {
border-top: 2px solid #dee2e6;
}
/* Style the last data row before a sub-total */
.data-row + .sub-total {
border-top: 1px solid #dee2e6;
}
This demonstrates how combining simple pseudo-selectors and combinators gives us contextual power.
:has()
Section 2: The Game Changer - Relational Pseudo-class The :has()
pseudo-class is arguably the most significant addition to CSS in years. It's often called the "parent selector" because it allows you to select an element based on what it contains. This unlocks patterns that were previously impossible without JavaScript.
Let's revisit our zebra-striping problem. We want to style every second tr
that has the class .data-row
. With :has()
, we can't directly count class names, but we can solve other, more interesting problems.
Styling Based on Content
Imagine we want to make any row containing a negative amount (indicated by parentheses in accounting) appear with a subtle red tint. Previously, we'd add a .negative-row
class with JavaScript.
With :has()
, it's pure CSS:
/* Select any <tr> in the tbody that contains a <td> with text containing '(' */
.balance-sheet tbody tr:has(td:first-of-type:not([colspan])::before) {
/* Our zebra striping! We select every second row that has our generated content */
}
/* And for the actual coloring of the data... */
.balance-sheet tbody tr:has(td.amount:first-of-type:not([colspan])::before) {
background-color: #f1f8ff; /* A nice light blue for even rows */
}
Wait, this is complex. Let's simplify. The core idea of :has()
is selecting a parent. Let's try a clearer example. Let's make the text of any row containing a negative value red.
/* Select any <tr> in the tbody that contains a <td> whose text starts with '(' */
.balance-sheet tbody tr:has(td.amount:where(:contains('('))) {
color: #c0392b;
}
/* Note: :contains() is non-standard but supported in many libraries and proposed for CSS. A more robust way might be with attributes */
/* A more robust approach using a data-attribute */
/* HTML: <td class="amount" data-value="-20000">($20,000.00)</td> */
.balance-sheet tbody tr:has(td[data-value^="-"]) {
color: #c0392b;
font-style: italic;
}
This is incredibly powerful. We've just added conditional logic to our styling based on the content of a child element, without a single line of JS.
Styling Form Elements Based on State
Here's another great example. Imagine you have a form. You can style a div
wrapper around an input field when that input is invalid:
/* Style the parent div when its child input is invalid */
.form-group:has(input:invalid) {
background-color: #fff0f0;
border-left: 3px solid #e74c3c;
}
:is()
, :where()
, and :not()
Section 3: Logical Pseudo-classes - These selectors help you write cleaner, more efficient, and more maintainable CSS by grouping, excluding, or modifying the specificity of other selectors.
:is()
- Reducing Repetition
The :is()
pseudo-class takes a list of selectors and matches any element that can be described by one of them. It's perfect for applying the same style to multiple elements without repeating the entire rule.
In our balance sheet, the .sub-total
, .grand-total
, and .section-header
rows should all have a bolder font.
Before :is()
:
.balance-sheet .sub-total td,
.balance-sheet .grand-total td,
.balance-sheet .section-header td {
font-weight: 600;
}
After :is()
:
.balance-sheet :is(.sub-total, .grand-total, .section-header) td {
font-weight: 600;
}
Cleaner, right? The specificity of :is()
is the specificity of its most specific argument.
:where()
- The Zero-Specificity Wonder
:where()
is identical to :is()
in function, but with one crucial difference: it always has zero specificity.
Why is this useful? It's fantastic for creating baseline or default styles in a CSS library, reset, or framework. These styles will do their job but are incredibly easy for a user to override later without needing !important
or complex selectors.
Let's set a default text alignment for all table cells but make it easily overridable.
/* This rule has zero specificity for the :where() part */
.balance-sheet :where(th, td) {
text-align: left;
padding: 0.75rem;
}
/* This simple class selector will easily override the above rule */
.amount {
text-align: right;
}
Because :where(th, td)
has no specificity, the single class selector .amount
wins for text alignment without any fuss.
:not()
- The Exclusion Principle
:not()
is a negation pseudo-class. It takes a selector as an argument and matches elements that are not represented by that argument. It's been around for a while, but it's become much more powerful as it can now take a list of selectors.
Let's add a bottom border to all rows in the <tbody>
except for the very last one and the note rows.
.balance-sheet tbody tr:not(:last-child, .note-row, .section-header) {
border-bottom: 1px solid #e9ecef;
}
This single line elegantly handles multiple exclusions, keeping our styling logic clean and readable.
Section 4: Pseudo-Elements - Styling the Unseen
Pseudo-elements let you style a specific part of a selected element. They are distinguished by a double colon ::
.
::before
and ::after
- Adding Content
::before
and ::after
create a pseudo-element that is the first or last child of the selected element. They are most often used with the content
property to add cosmetic content.
Let's add a visual cue to our asset and liability rows. We can use ::before
to add a subtle indicator.
.data-row.asset > td:first-child::before {
content: '▲';
color: #28a745;
margin-right: 8px;
font-size: 0.8em;
}
.data-row.liability > td:first-child::before {
content: '▼';
color: #dc3545;
margin-right: 8px;
font-size: 0.8em;
}
Now our assets and liabilities are clearly marked without adding extra HTML elements. We're targeting the first <td>
of each row type to place the indicator.
::marker
- Styling List Bullets
While we're using a table here, ::marker
is a fantastic pseudo-element for when you're working with <ul>
or <ol>
lists. It allows you to directly style the bullet or number of a list item.
/* Example for a list */
ul li::marker {
color: var(--primary-color);
font-size: 1.2em;
}
::selection
- Customizing Text Selection
Finally, a nice touch of polish. The ::selection
pseudo-element applies styles to the part of a document that has been highlighted by the user.
.balance-sheet::selection {
background-color: #007bff;
color: white;
}
This simple addition can make your site feel more branded and cohesive.
Putting It All Together: The Final CSS
Now let's combine all these techniques into a final stylesheet for our balance sheet. The result is a clean, readable, and highly maintainable set of rules that create a sophisticated-looking component.
/* --- Basic Setup --- */
.balance-sheet {
width: 100%;
max-width: 800px;
margin: 2rem auto;
border-collapse: collapse;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 1rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
border-radius: 8px;
overflow: hidden; /* For border-radius to work on tables */
}
/* --- General Cell & Header Styling --- */
.balance-sheet :where(th, td) {
text-align: left;
padding: 1rem;
}
.balance-sheet th {
background-color: #f8f9fa;
font-weight: 600;
border-bottom: 2px solid #dee2e6;
}
.balance-sheet .amount {
text-align: right;
font-family: 'Fira Code', 'Courier New', monospace;
}
.balance-sheet caption {
caption-side: top;
padding: 1rem;
font-size: 1.25rem;
font-weight: 600;
text-align: left;
background-color: #343a40;
color: white;
}
/* --- Logical Selectors for Row Types --- */
.balance-sheet :is(.sub-total, .grand-total, .section-header) {
font-weight: 600;
background-color: #f8f9fa;
}
.balance-sheet .section-header td {
color: #007bff;
font-size: 1.1rem;
}
/* --- Structural & Relational Selectors --- */
/* Add a border to all data rows except the last one of its kind */
.balance-sheet tbody tr.data-row:not(:last-of-type) {
border-bottom: 1px solid #e9ecef;
}
/* Zebra-striping only for data-rows using sibling combinator */
.balance-sheet .data-row + .data-row:nth-child(even) {
background-color: #fdfdfd; /* A very subtle striping */
}
/* Style rows with negative values using :has() and a data attribute */
/* HTML: <td class="amount" data-value="-20000">($20,000.00)</td> */
.balance-sheet tbody tr:has(td[data-value^="-"]) {
color: #c0392b;
}
/* --- Pseudo-elements for Polish --- */
.balance-sheet .data-row.asset > td:first-child::before {
content: '▲';
color: #28a745;
margin-right: 10px;
font-size: 0.8em;
}
.balance-sheet .data-row.liability > td:first-child::before {
content: '▼';
color: #dc3545;
margin-right: 10px;
font-size: 0.8em;
}
.balance-sheet .verification .status-ok {
color: #28a745;
font-weight: bold;
}
/* --- Footer Styling --- */
.balance-sheet tfoot {
border-top: 2px solid #343a40;
color: #343a40;
}
.balance-sheet .grand-total td {
font-size: 1.1rem;
}
/* --- Selection Style --- */
.balance-sheet::selection {
background-color: #007bff;
color: white;
}
Conclusion: CSS is More Powerful Than You Think
As we've seen by building this balance sheet, modern CSS is not just a styling tool; it's a language for describing the logic of a user interface. By mastering advanced pseudo-selectors, you can:
- Reduce JavaScript: Many tasks that once required JS for DOM traversal and class manipulation can now be handled by pure CSS, leading to better performance and simpler codebases.
- Write Cleaner HTML: Pseudo-elements and relational selectors let you create richer visuals without cluttering your markup with extra
<span>
s or<div>
s. - Create More Maintainable Stylesheets: Logical selectors like
:is()
and:not()
reduce repetition and make your styling intent clearer and more concise.
So next time you're faced with a complex styling challenge, before reaching for JavaScript, take a moment to think about the relationships between your elements. The solution might just be a pseudo-selector away.