WasiLearn Code
CSS · 1. First steps with CSS

Descendant, child and sibling selectors

You style a to make links crimson, and the footer links go crimson too. You want only the navigation. So you try .nav.link, get nothing, try .nav .link, and it works — and you have no idea why the space mattered.

The space mattered because it is an operator. So is >, so is +, and so is the comma. These are combinators: characters that sit between two selectors and describe the relationship you want.

nav a {
  color: crimson;
}

Every <a> inside <nav> turns crimson. The link in the footer does not. That one space is doing all the work.

A space means "somewhere inside"

nav a is the descendant combinator. Read it right to left: an a, that has a nav somewhere above it. Depth is irrelevant.

<nav>
  <ul>
    <li><span class="tag"><a href="/routes">Routes</a></span></li>
  </ul>
</nav>

That link is four levels down and nav a still matches it. There is no limit, and you can chain as many as you like: nav ul li a is legal, though it is usually more specific than you need.

This is the combinator you will use most, and the one that quietly causes the most trouble, because "somewhere inside" is a very wide net.

> stops at one level

A > B matches a B whose direct parent is an A. Nothing deeper.

Take a navigation with a submenu — top-level destinations, and cities nested underneath one of them:

<nav>
  <ul>
    <li><a href="/">Home</a></li>
    <li>
      <a href="/routes">Routes</a>
      <ul>
        <li><a href="/routes/karachi">Karachi</a></li>
        <li><a href="/routes/lahore">Lahore</a></li>
      </ul>
    </li>
  </ul>
</nav>

There are four <li> elements here. Watch what each selector reaches:

nav li        { border-bottom: 1px solid gainsboro; }  /* all four */
nav > ul > li { font-weight: bold; }                   /* the top two only */
nav > li      { color: red; }                          /* nothing at all */

That last one is worth staring at. nav > li matches nothing, because the <nav>'s only child is a <ul>. The <li> elements are grandchildren. > does not skip generations, and it will not warn you that it found nothing.

Reach for > whenever a component can contain a copy of itself — menus, comment threads, nested lists — because that is exactly where a descendant selector leaks into the nesting you meant to leave alone.

+ and ~ reach sideways

A + B is the next-sibling combinator: a B that comes immediately after an A, with the same parent and no element in between.

h2 + p {
  font-size: 1.15rem;
  color: dimgray;
}

The first paragraph after every subheading becomes a standing-out lead paragraph. The second paragraph is untouched, because a <p> sits between it and the <h2>.

A ~ B is the subsequent-sibling combinator: every B after an A, same parent, however far along.

h2 ~ p {
  margin-left: 1rem;
}

Both look forward only. There is no "previous sibling" combinator. If you need to style something based on what comes after it, that job belongs to :has(), which is a later lesson.

A comma means "or" — and one typo voids the lot

A comma is not a combinator. It builds a selector list: apply this block to anything matching any one of these.

h1,
h2,
h3 {
  letter-spacing: 1px;
  font-family: Georgia, serif;
}

That is one rule, three selectors, and it is far better than three copies of the same block that will drift apart the moment you edit one of them.

Now the trap. A selector list is all-or-nothing. If the browser cannot parse one selector in the list, it throws away the entire rule:

h1,
h2,
h3::poetry {
  letter-spacing: 1px;
}

::poetry is not a thing. So h1 gets no letter-spacing, h2 gets none either, and nothing in devtools points at the misspelt bit. You just see three headings that ignored you.

This is why :is() and :where() exist — their lists are forgiving, and a nonsense entry inside them is dropped on its own without harming the rest:

:is(h1, h2, h3::poetry) {
  letter-spacing: 1px;    /* h1 and h2 still get this */
}

The space that changes everything

Here is the misreading that costs beginners the most time. These two look like the same selector with a stray space:

.menu .item { color: teal; }
.menu.item  { color: crimson; }

They are not related at all.

  • .menu .item — with a space — means an element with class item, sitting somewhere inside an element with class menu. Two elements.
  • .menu.item — no space — means one single element that carries both classes at once. One element.

Put both shapes on one page and it becomes obvious:

<div class="menu">
  <p class="item">Chicken roll — 250 rupees</p>
</div>

<p class="menu item">Fresh lime soda — 120 rupees</p>

The chicken roll goes teal: it is an .item inside a .menu. The lime soda goes crimson: it is one paragraph whose class attribute holds both names, so .menu.item matches it. Neither rule matches the other element. Swap the markup around and both rules go quiet.

So when a selector "matches nothing", read it out loud with the space as a word. .card .title is "a title inside a card". If your markup is <h2 class="card title">, there is no title inside anything — the class is on the card itself, and you wanted .card.title.

The same reading works the other way. If you wrote .card.title and your markup has an <h2 class="title"> nested inside <div class="card">, you are asking for one element wearing both labels, and no such element exists.

Selecting by attribute

Classes are not the only hook. Anything in the HTML that you can see in the attribute is fair game, written inside square brackets:

[disabled]            { opacity: 0.5; }      /* attribute present at all */
[type="email"]        { text-transform: lowercase; }
[href^="https"]       { font-weight: bold; }  /* value starts with */
[href$=".pdf"]        { color: firebrick; }   /* value ends with */
[class*="btn"]        { cursor: pointer; }    /* value contains */

^=, $= and *= are start, end and contains. They are exact and case-sensitive by default; add an i before the closing bracket — [href*="karachi" i] — to ignore case.

Attribute selectors are the tool for styling markup you do not control: a form generated by a framework, or a page where you simply are not allowed to add classes.

A form styled without touching the HTML

Text inputs and email inputs should look alike. Checkboxes should not — giving a checkbox a 100 per cent width and a big padding makes a mess. All three are <input>, so a type selector cannot tell them apart. The attribute can:

input[type="text"],
input[type="email"] {
  display: block;
  width: 100%;
  padding: 0.6rem;
  border: 1px solid silver;
  border-radius: 4px;
}

input[type="checkbox"] {
  width: auto;
  margin-right: 0.4rem;
}

The first rule is a selector list, so both fields share one block. The checkbox never matches it, so it keeps its native size — no reset needed, because nothing was applied to it in the first place.

Which one to reach for

Default to a descendant space; it is the most forgiving and the most readable. Switch to > the moment nesting exists, or the leak will find you. Use + for "the thing right after", and a comma whenever two selectors genuinely deserve identical treatment — never to save typing on rules that are only accidentally the same today.

And when a rule refuses to apply, read the selector aloud before you touch anything else. Half the time the space, or the missing one, is the whole story.

Next: file paths, because the second commonest reason CSS "does not work" is that the browser never loaded the file. If a class rule is still misbehaving, the selectors lesson covers the dot-and-hash rule, and the syntax lesson covers what a single missing semicolon does to everything below it.

Prefer learning with a teacher?

WasiLearn Academy runs small-group classes covering this material, online and in Karachi.

See the classes