Published on

Advanced CSS Pseudo-Selectors Explained by Building a Financial Balance Sheet

Authors

'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

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.

Section 1: The :nth-child Family - Mastering Structure

Structural pseudo-classes select elements based on their position in the document tree. The :nth-child family is the most powerful and versatile group.

Zebra-Striping with :nth-child()

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.

The Power of :nth-of-type()

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.

Section 2: The Game Changer - Relational Pseudo-class :has()

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;
}

Section 3: Logical Pseudo-classes - :is(), :where(), and :not()

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:

  1. 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.
  2. Write Cleaner HTML: Pseudo-elements and relational selectors let you create richer visuals without cluttering your markup with extra <span>s or <div>s.
  3. 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.