For years, a client asking for a custom booking calendar meant reaching for a heavy JavaScript library. It was the safe bet. It was also a maintenance problem, because managing the visual state of a CSS Date Range Selector in JavaScript means toggling classes, recalculating indices, and hoping nothing races the DOM update.
Modern CSS handles this now. The :nth-child(n of selector) syntax hands range styling to the browser’s engine. You write less code, and the styling keeps working when a user clicks too fast or a script fails to load.
How the “of” syntax works
The difference between .item:nth-child(2) and :nth-child(2 of .item) trips up most people. The standard selector finds the second child and then checks whether it carries the class, so if it does not, nothing matches. The “of” syntax filters the children by class first, then picks the second one out of that filtered list.
/* This fails if the 2nd child isn't an .accent */
.accent:nth-child(2) {
font-weight: bold;
}
/* This successfully finds the 2nd element with .accent regardless of its position */
:nth-child(2 of .accent) {
text-decoration: underline;
}
For more of these newer selectors, my guide on styling the ::search-text pseudo-element covers the accessibility side.
Building the calendar grid
Refactoring a legacy project onto CSS Grid showed me a few ways a calendar can break that I had not met before. The markup is a plain unordered list, and about three lines of CSS turn it into a responsive month layout. Each date is an <li> with a hidden checkbox inside it to carry the checked state.
<ul id="calendar">
<li class="date">01<input type="checkbox" value="01"></li>
<li class="date">02<input type="checkbox" value="02"></li>
<!-- and so on -->
</ul>
#calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
list-style: none;
}
Styling the CSS date range selector
JavaScript still manages the checkbox states when a user picks two dates, but the CSS Date Range Selector styling comes from combining :nth-child(of) with the sibling combinator (~). The goal is to style everything sitting between the first checked item and the second.
/* When two dates are selected */
.isRangeSelected {
/* Select dates following the first checked... */
:nth-child(1 of :has(:checked)) ~ :not(:nth-child(2 of :has(:checked)) ~ .date) {
background-color: rgb(228 239 253);
border-radius: 0;
}
}
Read that compound selector out loud and it says: find everything after the first checked box, and stop once you reach the elements following the second checked box. The browser does the work, so there are no loops and no index math in JavaScript.
Managing state with vanilla JS
A small amount of logic still has to keep the selection at two dates. When someone clicks a third date, something has to decide whether that click becomes the new start or the new end. Per the MDN spec for :nth-child, you can target the first, second, or third checked box directly and uncheck the redundant one.
const CAL = document.getElementById('calendar');
CAL.addEventListener('change', e => {
if (!CAL.querySelector(':checked')) return;
// Toggle the range class if exactly two are checked
CAL.classList.toggle('isRangeSelected', !!CAL.querySelector(':nth-child(2 of :has(:checked))'));
// Logic for the third click
if (CAL.querySelector(':nth-child(3 of :has(:checked))')) {
// Uncheck the middle date or adjust as needed
CAL.querySelector(':nth-child(2 of :has(:checked)) input').checked = false;
CAL.classList.add('isRangeSelected');
}
});
If front-end work like this is eating your dev hours, I can take it off your plate. I have been wrestling with WordPress since the 4.x days.
What this buys you in performance
Moving the range visualization into CSS cuts DOM mutations and script execution time. The UI feels quicker for it, most noticeably on mobile, where heavy JavaScript shows up as input lag. Support for :nth-child(of) is now broad across modern browsers, which leaves the old hacks with no real justification.