Everything the adaptive question bank can teach and test in CSS, from foundations through advanced practice. Work through it in order, or start practicing and let the questions find your level.
The semicolon separates declarations rather than ending them, so the last one in a block may omit it. That leniency is a trap: add a new line after it without inserting the missing ; and the parser reads both lines as one invalid value, losing both. The same list, minus the braces, is exactly what an HTML style attribute holds.
Recovery skips ahead only to the next ; or }, so a typo such as colr: blue or width: 10 px costs that one declaration, and the browser reports nothing. Authors rely on this for fallbacks: write display: inline-flex; and then display: inline flex;, and an engine that rejects the second keeps the first. Because the failure is silent, check devtools, which flags the rejected line.
Writing background: url(bg.png) after background-color: red turns the red back to transparent behind the image, because the shorthand quietly reset the color. So order matters: put a longhand after the shorthand when you want to adjust one piece. A few shorthands define their own defaults instead of the initial value; flex: 1 sets a zero basis, not the initial auto.
An important author declaration also outranks normal user and browser styles and running animations; only transitions sit above every important value. Between two important declarations in the same origin and layer, specificity and then order decide again. Put on a shorthand, the flag marks every longhand it expands to, and inside @keyframes it makes that declaration ignored.
A comment can sit anywhere whitespace is allowed, but comments cannot nest: the first */ closes it, so commenting out a block that already holds a comment ends early and leaves stray code. A // line is not a comment. Inside a block it eats text up to the next semicolon, usually taking the following declaration with it. Sass accepts //, which is how it leaks into plain CSS.
@import, @namespace and a list such as @layer base, theme; are statements, while @media, @supports, @font-face and @keyframes carry blocks; @layer comes in both forms. The parser decides by whichever arrives first, a ; or a {. A stray semicolon after a block rule's prelude therefore ends it as an empty statement, and the block that follows is read as a separate, broken rule.
Apart from other imports, only @charset and @layer statements may come before it. An @import placed after any style rule is silently ignored, and it cannot sit inside @media. The speed cost is separate: the browser learns the imported URL only after it has fetched and parsed the sheet that names it, so each level adds a round trip before the page can render. Several <link> elements download in parallel instead.
Ordinary property names and keywords ignore ASCII case, so COLOR: RED works. A custom property name is compared exactly, so var(--Brand) finds nothing when only --brand exists; it uses its fallback, or the declaration becomes invalid at computed-value time. Custom properties also inherit by default and accept almost any value, so a typo in one surfaces only where var() reads it.
Children inherit the computed value, not what was written: a parent's 2em passes down as pixels, while a percentage width stays a percentage until layout turns it into the used value. The actual value adds device limits, such as rounding a border to whole device pixels. getComputedStyle() reports the resolved value, which is the computed value for most properties but the used value for width and height.
A CSS-wide keyword must be the entire value: margin: 0 inherit is invalid, so the whole declaration is dropped. Each one names a different fallback: the spec default, the parent's computed value, whichever of those suits the property, the previous origin, or the previous cascade layer. None is valid as an unquoted custom name, such as a grid-area or @keyframes name. CSS Cascade 5 also defines a sixth, revert-rule.
Prefixes such as -webkit- and -moz- marked experimental versions; browsers now put new features behind flags instead. Since an unknown declaration is skipped, list prefixed forms first and the standard property last, so it wins by source order where both are known. Selectors are harsher: one unknown prefixed pseudo-element voids its whole selector list, except -webkit- ones without parentheses, which engines tolerate.
B. Cascade, specificity and inheritance
Origin is compared before specificity, so a user stylesheet rule :root p loses to a plain author p. The same ordering explains a classic surprise: the browser hides elements carrying the hidden attribute with a normal display: none rule, so an author display: flex on such an element makes it visible again. Animations sit above every origin's normal declarations.
The reversal lets readers enforce needs such as larger text or high-contrast colors that no site can override, even with its own !important. Browser defaults marked important sit higher still: the HTML stylesheet gives input[type=hidden] an important display: none, so no author rule can reveal one. Inside the author origin, important declarations in earlier cascade layers beat later ones.
The full sort in CSS Cascade 5 has two more steps before layers: encapsulation context, such as shadow trees, and then a check that ranks style attribute declarations above selector-based ones. Each step only breaks a tie left by the step before it. @scope, from Cascade 6, adds proximity between specificity and source order, so the rule whose scope root is fewer DOM levels away wins a tie.
Write it as ID-CLASS-TYPE: #nav a:hover scores 1-1-1 and ul li.active scores 0-1-2. An attribute selector counts as a class even when it tests id, so [id="nav"] is only 0-1-0, a common way to lower an ID rule's weight. Pseudo-elements count as types. In a comma list each selector is scored on its own, and the one that actually matched sets the weight.
Specificity is not a base-10 number: 0-11-0 loses to 1-0-0, because the columns are compared left to right and the first difference decides. Point systems that give 100 per ID and 10 per class mislead once a column passes nine. Since no stack of classes reaches an ID, the usual fix is lowering the ID rule's weight with :where() or an attribute selector instead.
In CSS Cascade 5 the style attribute is not really a specificity: it has its own step ahead of cascade layers, so a normal inline declaration also beats layered and unlayered author rules alike. Any author rule marked !important beats it, and an inline !important beats every important author rule. That is how styles written inline by a script are usually overridden.
Order is judged across the whole document: linked sheets in link order, imported sheets as if pasted where their @import sits, and style attributes after every sheet. Moving one <link> below another can therefore flip which equal-weight rule wins. Rules inside @media gain no weight, so a media block placed before the base rule loses to it.
* and combinators still narrow what matches; they just add 0-0-0, so div > p scores the same as div p. :where() discards the weight of everything inside it, which suits library defaults that any single class should override. The & nesting selector also adds nothing itself, though the selectors it stands for do.
The heaviest argument counts even when a lighter one is what matched, so :is(p, #intro) span scores 1-0-1 for every paragraph span on the page. A harmless-looking list can therefore become hard to override. :has() and CSS nesting follow the same rule, while :where() matches exactly like :is() at zero weight.
As a rough pattern, text properties inherit and box properties do not, which is why font-family on body styles the whole page while a border stays put. Each property's definition says Inherited: yes or no. Form controls are where you notice exceptions: the HTML stylesheet resets line-height, letter-spacing and text-transform on input, button and textarea, so they ignore the page until you write inherit.
initial means the spec's default, not the browser's: display: initial gives inline even on a div, and color: initial gives the initial color, typically black, rather than the page's text color. inherit copies the parent's computed value, so a parent's 1.5em padding arrives as pixels, and it forces inheritance even on properties that normally do not inherit, such as border.
revert removes the current origin's declarations, so on a strong styled in author CSS it brings back the browser's bold, where unset would inherit the parent's normal weight. Written in a user stylesheet, revert drops to the browser default; inside the browser's own styles it equals unset. revert-layer rolls back only the current cascade layer.
A rule never wins as a unit. If .card sets color and padding and a more specific #promo sets only color, the element takes its color from #promo and its padding from .card. Devtools reflect this by striking through the overridden declaration, not the rule. A shorthand counts as all its longhands, so a later margin-top beats just one piece of an earlier margin.
all accepts only the CSS-wide keywords, and it also leaves custom properties alone, so --theme variables still flow into a reset component. The exempt direction and unicode-bidi belong to markup and are set with the dir attribute. all: initial is the blunt choice: it wipes browser defaults too, turning a div inline, while all: revert returns to them.
Layer order is fixed by first appearance, so a statement such as @layer reset, base, components; at the top settles it before any rules load, and reopening a layer later appends rules without moving it. Unlayered author styles outrank every layer, so third-party CSS left unlayered beats your layered code. For !important the order reverses: the earliest layer's important declarations win.
The HTML specification describes these defaults: display: block for div and p, 1em block margins on paragraphs, an 8px body margin, blue underlined links. They come from the lowest-ranked origin, so a single author selector overrides them, and resets such as normalize.css even out differences between browsers. revert brings a property back to them.
C. Selectors and combinators
A type selector (p) weighs 0-0-1, a class or attribute 0-1-0, an ID 1-0-0 and * nothing. An id should be unique in the page, but CSS does not enforce that: #promo styles every element carrying that id. Outside quirks mode, class and ID matching is case-sensitive, so .Btn misses class="btn".
[disabled] matches whether the attribute reads disabled, empty, or even false, since only presence is tested, and a boolean attribute that is present is on. [class="btn"] must equal the whole value, so it misses class="btn primary"; use .btn or [class~="btn"] for one word. Quote values that contain spaces or punctuation.
~= splits the value on whitespace and needs a whole word, so [class~="btn"] behaves like .btn, while *= would also hit btn-large. |= matches en and en-US but not english, which suits lang and hreflang. An empty string never matches with ^=, $=, *= or ~=, so [href^=""] selects nothing.
The flag folds ASCII letters only, so [href$=".PDF" i] catches .pdf and .Pdf. HTML already compares some enumerated values, such as type, without regard to case, while class, id, data-* and ARIA values are case-sensitive unless you add the flag. Attribute names themselves are case-insensitive in HTML either way.
nav a keeps matching links nested inside lists and wrappers at any depth, which is why menu styles leak into dropdowns. nav > a stops at the first level, so wrapping each link in an li silently breaks it. Neither combinator adds specificity, so switching between them changes only what matches, never which rule wins.
Both look only forward, and only among children of the same parent: h2 + p styles the paragraph right after a heading, h2 ~ p every later paragraph even with other elements between. Text between elements is ignored when judging adjacency. To style the earlier sibling instead, use :has(): h2:has(+ p) selects headings followed directly by a paragraph.
Every combinator points from an ancestor to a descendant or from an earlier sibling to a later one, and the element styled is the rightmost compound. A classic selector can therefore require that something contains or precedes your element, never test what it contains or what follows it. :has() fills that gap: form:has(:invalid) styles the whole form while any field is invalid.
:focus matches whenever an element has focus; :focus-visible only when the browser judges a ring useful, such as after keyboard navigation but not a mouse click on a button, so restyle rings there instead of removing outlines. :focus-within matches ancestors of the focused element. Order link rules :link, :visited, :hover, :active, and do not rely on :hover on touchscreens.
The pattern is tried for n = 0, 1, 2 and so on, and only positive results select anything, so -n+3 yields 3, 2, 1 and then stops, while 3n+1 picks 1, 4, 7. odd and even are shorthand for 2n+1 and 2n. Because counting starts at 1, :nth-child(0) never matches, and :nth-last-child() applies the same pattern from the end.
p:nth-child(2) means the second child, and only if it is a p; when a heading comes first, the second paragraph is child 3 and is missed. p:nth-of-type(2) counts only p siblings and finds it. Type means tag name, so .item:nth-of-type(2) ignores classes when counting; to count matches of a class, write :nth-child(2 of .item) (baseline 2023).
:only-child equals :first-child:last-child at lower specificity. These count element siblings only, so a lone span inside a sentence is still :only-child. :empty is stricter: one space or newline is a text node and makes the element non-empty, while comments do not. Selectors Level 4 drafts treat whitespace-only elements as empty, but browsers have not shipped that change.
All three take a selector list, and none may contain a pseudo-element, so :is(::before) is invalid. :is(header, footer) a replaces two longer selectors, and :not(.a, .b) excludes elements matching either class. Since :is() takes the weight of its heaviest argument while :where() weighs nothing, use :where() for defaults you expect others to override.
In a:hover, a:fancy the unknown :fancy drops the entire rule, so adding a new pseudo-class to an existing list can break styling in older engines. :is() and :where() parse each argument separately and ignore the bad one, so :is(a:hover, a:fancy) stays safe. :not() and :has() take ordinary lists, so one bad argument still invalidates them.
Its argument is a relative selector, so article:has(> img) means a direct child image and h2:has(+ p) a heading immediately followed by a paragraph. It cannot be nested inside another :has() or contain pseudo-elements, and it takes the weight of its heaviest argument. An engine without support drops the whole rule, unless you wrap it in :is() or :where().
With no content, or with none or normal, the box is not generated, so a decorative box needs content: "". It becomes the first or last child inside the element, inline by default, so it inherits from that element and is clipped by its overflow. It cannot attach to replaced elements such as img, and screen readers handle its text unreliably, so keep meaning in the markup.
Each accepts a short list of properties. ::first-line and ::first-letter work on block containers and take font, color and background properties; ::first-letter adds margins, borders and float for drop caps. ::selection takes color, background-color, decorations and text-shadow but ignores background-image. ::placeholder takes the ::first-line list, and ::marker styles bullets with font, color and content.
:root is html in HTML but weighs 0-1-0 against 0-0-1, a common home for global custom properties. :target matches the element whose id equals the URL fragment and moves as the hash changes. :lang(en) also matches en-US and inherited language, unlike [lang|=en], which needs the attribute on the element. :dir() reads document direction, not the direction property.
These follow live state, not attributes: :checked tracks clicks even though the checked attribute never changes. :invalid matches an empty required field as soon as the page loads, before anyone types, so error styles on it nag users; :user-invalid waits for interaction (baseline 2023). An empty field with min and max matches :in-range, never :out-of-range.
document.querySelector('p::before') returns null, and a click on generated content is reported on the element that owns it, so script cannot target one directly. Read its styles with getComputedStyle(el, '::before'), and change it by toggling a class on that element. An img or video never gets ::before or ::after, because its content comes from outside the document.
D. The box model
A background paints under the border by default, which background-clip changes, while the margin is always transparent. box-shadow is cast outside the border edge, and outline sits outside it without taking space. Vertical margins can merge with a neighbor's, so the margin box has no fixed size. On an inline box, padding and borders paint but do not push lines apart.
width: 100% plus padding: 1rem therefore overflows the parent by the padding and borders, a classic cause of a stray horizontal scrollbar. Four width: 25% columns with side padding wrap onto a second line for the same reason. The HTML stylesheet already gives button, select and several input types border-box, so a control and a div can size differently side by side.
The usual reset is *, *::before, *::after { box-sizing: border-box; }, because the property does not inherit and pseudo-elements need it too. Padding then eats into the content area instead of growing the box. The content area floors at zero, so padding larger than width makes the box wider than declared rather than collapsing it. Margins stay outside in both models.
With width: 200px, padding: 20px, a 5px border and 10px margins, the box paints 250px wide and takes 270px of the line. Switch to border-box and the same declarations paint 200px and take 220px. Vertical margins can collapse with neighbors, so the vertical version of this sum is not always the space a block uses.
A paragraph with margin-bottom: 24px above one with margin-top: 16px leaves a 24px gap. Collapsing needs in-flow block boxes in the same block formatting context: floats, absolutely positioned boxes and inline-blocks never collapse, and neither do flex or grid items. A sibling that must clear a float can also stop the two margins merging.
With no top border or padding, a child's margin-top appears outside the parent and pushes the parent down instead of spacing the child inside it, even when the parent's own margin is zero. Inline content before the child also prevents it, and at the bottom a set height or min-height does too. overflow: clip does not stop it; display: flow-root does, with no side effects.
Side by side, a 10px right margin next to a 10px left margin always leaves 20px. Flex and grid containers never collapse item margins in either direction, so switching a stack of cards from normal flow to display: flex; flex-direction: column can double the gaps between them. That is one reason gap is the safer spacing tool in those layouts.
Margins of 30px and -10px collapse to 20px. When every margin involved is negative, the most negative wins, so -30px and -10px give -30px. The same arithmetic applies through nested parents and children. Because the result can be negative, a following block can be pulled up to overlap the one before it.
Auto width is resolved from the container down: margins, borders and padding come off and the rest is the width. Auto height is resolved from the content up. That asymmetry explains a common surprise: a child given a percentage height inside a content-sized parent ends up auto. Give the parent a definite height, or let flex or grid stretch the child.
The base is the containing block's inline size, its width in horizontal writing, so padding-top: 50% inside a 400px-wide parent gives 200px whatever the parent's height. That quirk powered the old ratio box, where padding-top: 56.25% held a 16:9 space; aspect-ratio: 16 / 9 now does it directly. In vertical writing modes the base becomes the container's height.
Auto side margins split whatever is left after the box's width, so with width: auto nothing is left and they resolve to 0. Any limit that narrows the box works, max-width included. It centers block-level boxes in normal flow only: inline and inline-block elements ignore it, so center those with text-align on the parent. Vertical auto margins in normal flow resolve to 0.
A negative margin-top or margin-left moves the box itself, while a negative margin-bottom pulls the following block up over it. On a block with width: auto, negative side margins make it wider than its container, which is how full-bleed sections escape a parent's padding. When boxes end up overlapping, position: relative with a z-index decides which one paints on top.
Because it takes no space, adding an outline on focus never shifts the layout, unlike adding a border. outline-offset moves it inward or outward, and it follows border-radius corners. Removing outlines with outline: none erases the keyboard focus indicator unless you replace it, so restyle :focus-visible instead of deleting the ring.
E. Values and units
CSS fixes 1in at 96px, so 1pt is 1/72 inch (about 1.33px) and 1cm about 37.8px. On screens these follow the reference pixel, not a ruler, so a 1cm line rarely measures one physical centimeter; print output is where physical units tend to match. Fixed lengths also ignore the reader's default font size, which is why font-size is better set in rem.
Padding of 1em is 20px on a 20px heading but 14px on 14px body text, which keeps spacing in proportion to the text. Nesting compounds: lists set to font-size: 1.2em three levels deep reach 1.728 times the starting size. The value is computed into pixels before it is inherited, so children receive the length, not the em factor.
Because it skips every ancestor, rem keeps a component the same size wherever it is nested. It still scales with the reader's browser font setting, since the root default, usually 16px, follows that preference. html { font-size: 62.5% } makes 1rem equal 10px at that default. Used inside the root element's own font-size, rem refers to the initial value.
Inside font-size the element has no size of its own yet, so em borrows the parent's: font-size: 1.5em under a 20px parent gives 30px. Every other property on that element then uses the new 30px, so padding: 1em there is 30px, not 20px. A percentage in font-size works the same way as em.
Width, and padding and margins on every side, use the containing block's width; height, top and bottom use its height, and only when that height is definite. font-size uses the parent's font size, while transform: translate(-50%) uses the element's own box, the basis of the classic centering trick. A percentage line-height is computed once and inherited as a fixed length.
A plain factor is applied again for each descendant: line-height: 1.5 on a 10px parent gives a 20px child 30px lines. Writing 1.5em or 150% computes 15px on the parent, and every child inherits that fixed 15px, so larger text inside overlaps. Unitless is the safer default, and it is one of the few places where a bare number turns into a length.
vmin and vmax take the smaller or larger axis, so 100vmin gives the largest square that fits either orientation. Unless the root's overflow or scrollbar-gutter always reserves scrollbar space, viewport units are sized as if scrollbars did not exist, so where a classic scrollbar takes room, width: 100vw is wider than the visible page and scrolls sideways. width: 100% avoids that, as percentages measure the containing block.
On phones the browser toolbar collapses as you scroll, so the viewport has two heights. svh uses the smaller, toolbar-shown size and never hides content; lvh uses the larger one; dvh tracks the current size and changes while scrolling, which can make a layout resize and jump. As of 2026, browsers treat plain vh as lvh, so a 100vh hero can sit partly under the toolbar.
These follow the element's own font, so they change with font-family. ch is the width of the digit zero, so max-width: 60ch approximates 60 characters only in fonts with even widths; proportional text varies. ex is often near half an em. lh equals the element's computed line-height (baseline 2023), handy for spacing in whole lines.
Without spaces, calc(100%-20px) reads -20px as a negative number, leaving two values with no operator, and the declaration is dropped. * and / need no spaces. What matters is the final type: width: calc(10px * 2px) fails as a squared length, yet calc(10px * 2px / 1px) ends as a length and is valid where typed arithmetic has shipped, as it has in Chrome and Safari. calc() also cannot use auto.
width: min(100%, 600px) caps a box at 600px but lets it shrink on narrow screens, replacing a width plus max-width pair, and max() sets a floor the same way. The names feel backwards: min() acts as an upper limit. Arguments can mix units and contain math. The result is worked out at computed-value time, except a layout-dependent percentage, which waits for the used value.
Read it as floor, preferred, ceiling: font-size: clamp(1rem, 0.8rem + 1vw, 1.5rem) grows with the viewport between those limits. If the floor is larger than the ceiling, the floor wins, because the outer max() is applied last. Keep a rem term in the preferred value so the size still responds to zoom and font settings, not only to window width.
1turn equals 360deg, 400grad or about 6.2832rad, and a positive angle rotates clockwise. Times take s or ms and drive transitions and animations. Resolution units describe pixel density for media queries and image-set(): 1dppx equals 96dpi, x is an alias for dppx, and a 2x screen matches (min-resolution: 2dppx).
A bare 0 is fine for lengths but not for times or resolutions, so transition-duration: 0 is invalid where 0s works; a few legacy angle contexts accept a unitless zero, but write 0deg. Properties defined as a number or integer reject units entirely, so z-index: 2px and flex-grow: 1px are dropped.
F. Display and normal flow
An inline-block flows like a word but accepts width, height and vertical margins, and lays out its own contents as a block. Flowing like text brings two traps: the whitespace between inline-blocks in the HTML renders as a gap, and inline-blocks and images sit on the text baseline, leaving a small gap beneath. vertical-align or block display removes the second.
A span or a sizes to its text, so width: 200px and margin-top do nothing. Vertical padding and borders still paint, but they overlap neighboring lines instead of moving them, because line height, not padding, decides how tall each line is. To size the box, switch it to inline-block or block, or make its parent a flex container, which blockifies children.
display: none also removes the element and its descendants from the accessibility tree, and no child can undo it. visibility: hidden hides from screen readers too, but a child set to visibility: visible reappears, because the property inherits. A hidden element of either kind cannot take focus. To hide something visually while keeping it readable aloud, use a visually-hidden clipping pattern instead.
The outer type sets how the box sits among siblings, block or inline; the inner type sets how its children are laid out, such as flow, flow-root, flex or grid. Each legacy keyword maps to a pair: inline-block is inline flow-root, and a lone flex means block flex. The two-value form reached all major engines in 2023, so keep the one-word value before it as a fallback.
The axes depend on writing-mode: in English the block direction runs top to bottom and the inline direction left to right, but in vertical Japanese text blocks stack from right to left. Logical properties name these axes, so margin-block-start and inline-size follow the text while margin-top and width stay physical. Flexbox and grid alignment use the same start and end terms.
Inside a new block formatting context, floats are contained, so the parent grows to wrap them. Outside floats cannot intrude, so the box sits beside a float rather than underneath it, and margins do not collapse across its edge. Flex and grid containers form their own formatting contexts with the same isolating effects, and display: flow-root exists only to start a BFC without side effects.
overflow: hidden was the standard float-clearing fix, but it also clips shadows and dropdowns; flow-root does the same job cleanly. overflow: clip is the exception among non-visible values: it clips without becoming a scroll container and does not start a BFC. Inline-blocks, table cells, multicolumn containers and contain: layout or paint also establish one.
The element's own box disappears, so its border, padding, background and width stop rendering, while its children are laid out as if they belonged to its parent and still inherit from it. That lets a wrapper's children join a parent grid or flex layout. Some browsers have dropped such elements from the accessibility tree, so avoid it on a button or a list.
A div holding loose text and then a p gets an anonymous block wrapped around that text, and in a flex or grid container a bare text run becomes an anonymous item. These boxes inherit from their parent, but no selector can reach them, so the text cannot get its own margin or flex value. Wrap it in a span or p when it needs styling.
Their content comes from outside the document, such as an image file or a video stream, so CSS sizes the box but cannot style what is inside. img, video, iframe and embed are replaced; an input counts only with type="image". A natural width, height and ratio let height: auto keep proportions, and object-fit controls how the content fills the box.
G. Flexbox
Grandchildren are unaffected, so display: flex on a ul arranges its li items but not the links inside them. Items are blockified: an inline span child becomes a block-level flex item that accepts width. float and clear stop working on items, and an absolutely positioned child is not a flex item at all.
The axes are logical, not physical: with flex-direction: row on a right-to-left page, items start at the right. The -reverse values only change the direction of flow, so tab order and screen readers still follow the source. Property names read against these axes: justify-* for main, align-* for cross, and flex-basis sizes along whichever axis is main.
justify-content hands out free space along the main axis, so it does nothing once items fill the line. align-items places each item within its line on the cross axis, and by default items stretch. Flexbox has no justify-self or justify-items; to push a single item along the main axis, give it an auto margin.
With a column direction, justify-content: center centers vertically and align-items: center horizontally, the reverse of a row. Vertical justification needs a container taller than its items, so a column container with height: auto shows no change; give it a height or min-height. Items also stop stretching vertically and stretch to the container's width instead.
Growth starts from each item's basis, so with content-sized bases an item with flex-grow: 2 is not twice as wide as one with 1; it receives twice the leftover. Only positive free space is shared, and the default flex-grow: 0 leaves it after the last item. When the grow values total less than 1, only that share of the free space is handed out.
In a 600px container with bases of 100px and 200px and grows of 1 and 2, free space is 300px, so the items end at 200px and 400px. Item margins, borders and padding are subtracted along with the bases before free space is measured, and an item stopped by max-width is frozen while the rest share the remainder again. For equal widths whatever the content, start from a zero basis with flex: 1.
In a 300px container with bases of 200px and 400px and the default shrink of 1, 300px must go: the 400px item gives up 200px and the 200px item 100px, ending at 200px and 100px. Weighting by size keeps small items from being crushed first. No item shrinks below its automatic minimum, its content's min-content size, unless you set min-width: 0.
The initial flex-basis: auto defers to width or height, and falls back to content size when those are auto too. Any other basis overrides width on the main axis, though min-width and max-width still clamp the final size. In a column container the basis maps to height instead, and the content keyword sizes from content even when a width is set.
flex: initial equals 0 1 auto, and flex: none equals 0 0 auto, a fully rigid item. flex: 1 starts from a zero basis, so siblings share space equally whatever their content, down to their automatic minimum, while flex: auto starts from content size and gives bigger content more. A lone unitless zero is read as a grow factor, so write a zero basis with a unit.
Without wrapping the line never breaks: items shrink, then overflow the container. With flex-wrap: wrap, each line is laid out as its own flex container, so growing items on a short last line can end up wider than those above. That is why wrapped flex items cannot line up into columns across rows, which grid can do. wrap-reverse stacks new lines in the opposite cross direction.
It works on a multi-line container, one with flex-wrap: wrap, even when the items happen to fit on one line; with nowrap the single line fills the container's cross size, leaving nothing to distribute. It also needs spare cross space, such as a set container height. By default lines stretch, which is why wrapped rows spread apart until you set align-content: flex-start.
It takes the same values as align-items plus auto, which defers to the container's setting, and is often used to pin one item to the end of the cross axis. It has no effect when the item has auto margins on the cross axis, since those absorb the spare space first. Flexbox offers no main-axis counterpart.
Items sort by order, default 0, and then by source position, so order: -1 moves one item to the front. Keyboard focus and screen readers still follow the HTML, so a visually reordered list of links makes tabbing jump around. The Flexbox spec calls logical reordering with order non-conforming; change the markup when the reading order itself should change.
gap sets row-gap and column-gap and puts space only between items and between lines, never at the container's edges, so there is no last-item margin to remove. It does not collapse with item margins, which add to it. Wrapping counts the gap when deciding what fits on a line, so items wrap slightly earlier than they would without it.
margin-left: auto on the last link in a nav pushes it to the far end, the usual split-navigation pattern, and margin: auto on a lone item centers it on both axes. Auto margins take free space before justify-content and align-self act, so those stop affecting that item. When items overflow the container, their auto margins are ignored.
Stretching applies only when an item's cross size is auto and it has no auto cross margins, so a height on one item stops just that item from stretching. In a row the line's height comes from the tallest item or from the container's set height. An image stretched this way distorts, so give it align-self: start or an explicit height.
The floor is the item's min-content size, so a long URL, a wide table or a pre block forces its item, and the whole row, wider than the container. Set min-width: 0, or min-height: 0 in a column, or give the item an overflow other than visible or clip, which drops the automatic minimum to zero. The same floor makes flex: 1 siblings unequal when one holds unbreakable content.
H. Grid
The track list also defines the lines: three columns make four column lines, numbered from 1 at the start edge and from -1 at the end. Lines can be named in brackets, as in [main-start] 1fr [main-end], and several items may share one area and overlap. Rows you never list are still created as needed, as implicit tracks.
With grid-template-columns: 200px 1fr 2fr in an 800px container and no gap, the fixed track takes 200px and the other 600px splits into 200px and 400px. fr is a flex factor, not a length, so it cannot appear in calc() or as the minimum of minmax(). If the factors add up to less than 1, the tracks take only that fraction of the leftover.
The automatic minimum is the items' minimum content size, so one long word or wide image makes its 1fr column wider than its siblings and can push the grid past its container. Write minmax(0, 1fr) when columns must stay equal, accepting that content may then overflow its cell. repeat(3, minmax(0, 1fr)) is the usual equal-column pattern.
repeat() can sit beside other tracks, as in 200px repeat(3, 1fr) 200px, and can repeat line names too, but it cannot nest inside another repeat(). With auto-fill or auto-fit as the count, the browser works out how many fit, and the repeated tracks then need a definite size or a minmax() with one, so repeat(auto-fill, 1fr) is invalid.
With 200px 1fr 1fr, gap: 20px and an 800px container, the two gaps take 40px, the fixed track 200px, and each fr column gets 280px. Auto and content-sized tracks are sized before the remaining space is divided. A column whose content is wider than its share grows past it anyway, because 1fr carries an automatic minimum.
grid-auto-rows: minmax(100px, auto) gives rows a 100px floor that still grows with their content. If the maximum is smaller than the minimum, the maximum is ignored and the track is fixed at the minimum. An fr value works only as the maximum, and minmax(auto, 1fr) is exactly what a bare 1fr already means.
Both create as many tracks as fit and differ only when items run out. With repeat(auto-fit, minmax(150px, 1fr)), empty tracks collapse, so the existing items stretch across the row; with auto-fill, empty tracks keep their space and the items stay at one share each. The difference vanishes once there are enough items to fill every track.
Three columns have lines 1 to 4, so grid-column: 1 / 3 covers columns 1 and 2; off-by-one errors come from counting tracks instead of lines. If the end line comes before the start, the two are swapped, and a line number beyond the explicit grid adds implicit tracks to reach it. With no end given, the item spans one track.
grid-column: span 2 lets auto-placement choose the start and covers two tracks from there, while grid-column: 2 / span 3 starts at line 2 and ends at line 5. A span count must be a positive whole number; zero or a negative value is invalid. With a name, span main reaches to the next line called main.
Negative numbers count from the end of the explicit grid only, so 1 / -1 stops at the last line you defined and does not cover implicit columns added later. That keeps a full-width header working when the column count changes. In a grid with no explicit columns, line -1 is line 1, so 1 / -1 collapses to a single track.
Each string is a row and each word a cell, and . leaves a cell empty. Every row needs the same number of cells and each named area must form one filled rectangle; an L-shape or an uneven row invalidates the whole declaration. Items join an area with grid-area: header, and each area also creates header-start and header-end lines you can place against.
Implicit rows appear when there are more items than explicit cells, and implicit columns when an item is placed past the last line. Their default size is auto, so give grid-auto-rows a value such as minmax(80px, auto) for consistent rows. Negative line numbers ignore implicit tracks, and a subgridded axis creates none at all.
The default row flow fills each row along the inline direction before starting another; column fills each column in turn. Without dense, placement only moves forward, leaving holes when a spanning item does not fit. dense backfills those holes with later items, so the visual order stops matching the source, which scrambles reading and tab order for keyboard users.
Nothing can be placed in a gap, and line numbers ignore it: the gap just widens each line. Its space comes off the container before fr tracks are sized, so repeat(4, 25%) plus a gap overflows while repeat(4, 1fr) fits. No gap is added at the outer edges, and the older grid-gap name still works as an alias.
justify-items works along the inline axis and align-items along the block axis, inside each item's own area rather than the whole grid. By default items stretch to fill the area, except those with a natural aspect ratio, such as images, which keep their proportions. A value like start shrinks the item to its content size, and justify-self or align-self sets one item apart.
These move the tracks only when the tracks add up to less than the container, for example fixed 100px columns in a wide grid. Once any fr track exists it takes all free space, so justify-content: center seems to do nothing. Spacing values such as space-between widen the gaps between tracks, and items that span those gaps grow with them.
A single value sets both axes, so place-items: center centers every item in its area. With two values the first is the block axis (align-*) and the second the inline axis (justify-*), so place-items: end start puts items at the bottom left in left-to-right horizontal writing. place-self does the same for one item.
In auto 1fr, the auto column shrinks to its content and the fr column takes the rest, which suits a label beside a field. With no fr track present, leftover space goes to auto tracks through the default stretch alignment, so auto columns widen. As a maximum, auto acts like max-content but still allows that stretching.
A nested grid with grid-template-columns: subgrid uses the tracks it spans in its parent, so headers and footers line up across separate cards. It inherits the parent's gap but may override it, its line numbers restart at 1, and it creates no implicit tracks in that axis. Rows and columns are subgridded independently.
Flex sizes items from their content and spreads space within each line, so wrapped rows do not align into columns. Grid sizes the tracks first and places items into them, so rows and columns stay aligned. A common split is grid for page and card layouts and flex for a row of buttons or a nav bar, and they nest freely: a grid item can be a flex container.
I. Positioning and stacking
Inset properties never move a static box, and z-index does nothing on a static block, which is why a z-index that seems ignored usually needs position: relative. The exception is flex and grid items: on those, z-index works and creates a stacking context with no positioning at all. An absolute descendant looking for its containing block skips static ancestors.
top: 20px moves the painted box down 20px but neighbors stay put, so it can overlap them, and a percentage top resolves against the containing block's height. The usual reason to add position: relative with no offsets is the side effects: it becomes the containing block for absolute children, and with a z-index it creates a stacking context.
A parent holding only absolute children collapses to zero height, because those children no longer size it. With width: auto the box shrinks to fit its content instead of filling the line, and setting both left and right stretches it between them. Its margins never collapse, and it starts a new block formatting context for its contents.
Offsets are measured from that ancestor's padding edge, just inside its border, so top: 0; left: 0 lands inside the border. Percentage widths and offsets use the same padding box. An ancestor with a transform, filter, perspective or contain: paint also captures absolute descendants even while static, which can shift a popup unexpectedly.
With every inset auto, the box sits where it would have been in flow but no longer takes space, so following content moves under it. Setting only top keeps the horizontal static position and moves it vertically against the containing block, a handy way to hang a tooltip beside its trigger. Inside a flex container that position is worked out as if it were the only item.
A fixed box stays in place while the page scrolls and always creates a stacking context, and when printed it repeats on every page. Its percentages also measure the viewport, so width: 100% spans the window rather than the parent. Like an absolute box it leaves the flow, so content underneath needs padding to avoid being covered by a fixed header.
Any ancestor whose transform, perspective, filter, backdrop-filter or contain: paint is not none, or whose will-change names one of them, captures it: the fixed box then scrolls with that ancestor and is clipped by its overflow. A leftover translateZ(0) hack causes the same trap, so modals are safest as direct children of body. MDN notes engines disagree on filter and perspective.
With top: 0, the box scrolls normally until its top reaches the scroll container's edge, then stays there, but only while its parent is still in view: when the parent's bottom edge arrives, the sticky box is carried away with it. A sticky header inside a wrapper no taller than itself therefore never appears to stick. Its original space in flow is kept, as with relative.
With top, bottom, left and right all auto, sticky acts like relative. It sticks within the nearest ancestor that has a scrolling mechanism, and overflow: hidden, auto or scroll all create one even when nothing scrolls, so a hidden wrapper traps it. overflow: clip clips without creating a scroll container, so sticky still works through it. With no such ancestor, the viewport serves.
inset: 0 sets all four sides, and on an absolute box with auto size it stretches the box over its containing block, the usual overlay pattern. The shorthand follows margin order: top, right, bottom, left. When top and bottom both apply to a box whose height is fixed, top wins, and for left and right the direction decides. Logical forms such as inset-inline-start follow writing mode.
On a positioned box, any value other than auto creates a stacking context, while z-index: auto leaves the box in its parent's context. A negative value drops the box below in-flow content, and behind its parent's background too unless that parent forms a stacking context. Bigger numbers win only among boxes in the same context.
position: fixed and sticky always create one, as do flex and grid items with a z-index, isolation: isolate, mix-blend-mode, clip-path, contain: paint, and fullscreen or popover elements in the top layer. Each flattens its descendants into one layer of its parent, so a stray opacity: 0.99 can trap a dropdown. isolation: isolate creates one with no other side effect.
A child with z-index: 9999 inside a parent at z-index: 1 still sits under a sibling of that parent at z-index: 2, because the parent's whole context is stacked as one unit at level 1. Raising the child's number cannot help; raise the ancestor that forms the context, remove the property that creates it, or move the element elsewhere in the DOM, as portals do.
J. Backgrounds, borders and shadows
Any longhand you leave out of background goes back to its initial value, so background: url(a.png) written after background-color: navy turns the navy into transparent. The shorthand also resets background-blend-mode, which it cannot set at all. Write the shorthand first and longhand overrides after it, or put the color inside the shorthand's final layer.
Layers paint front to back: the first one listed sits closest to you and the last lies just above the color. Put a color in any layer except the final one and the whole declaration is invalid, so nothing paints. A common slip is listing a gradient overlay after a photo, which buries the overlay beneath it. Longhands such as background-size take their own comma list, matched layer by layer.
Both keep the image's proportions. cover scales until no empty space remains, so whichever axis overshoots gets cut off; contain scales until the whole image fits, so one axis falls short. Those leftover gaps are not blank by default: background-repeat is still repeat, so extra tiles fill them unless you add no-repeat. Pair cover with background-position to pick which part survives the crop.
The offset is (box size − image size) × percentage. That is why 50% centers and 100% lands the image flush against the right or bottom edge instead of pushing it out of the box. The failure case: when the image is exactly as large as the box the difference is zero, so every percentage gives the same result. A length works differently, offsetting the image's corner from the box's top-left corner.
The default, scroll, pins the image to the element's border, so when the element's own content scrolls inside it the image stays put; local makes it travel with that content. fixed locks the image to the viewport, so the page slides over it, and background-origin stops mattering because the viewport becomes the positioning area. Percentages in background-size then measure the viewport too.
Their defaults differ on purpose: background-origin starts at padding-box while background-clip reaches border-box. So a repeating image is positioned from the padding corner, yet its tiles still show through a dashed or translucent border. Set both to content-box to keep the image off the padding. One box keyword in the shorthand sets both; two keywords set origin first, then clip.
With no direction a gradient runs to bottom, the same as 180deg; angles turn clockwise from 0deg at the top, so 90deg points right. A stop with no position sits halfway between its neighbors, and two stops at the same position make a hard edge instead of a blend. A gradient is an image, so it belongs in background-image; background-color: linear-gradient(...) is invalid.
Values before the / set each corner's horizontal radius and values after it the vertical one, each list running clockwise from the top-left corner. A percentage measures that axis of the border box, so 50% on a wide box draws an ellipse. When neighboring radii add up to more than a side, the browser scales every radius down by one shared factor, which is why 9999px gives a clean pill.
Positive offsets push the shadow right and down; blur softens the edge without moving it, and spread grows or shrinks the shape before blurring. Leave out the color and the shadow uses the element's color. Shadows take no layout space and never cause scrolling. In a transition the lists pair entry by entry, and if one entry is inset while its partner is not, the whole list jumps instead of animating.
Toggling a 2px border on focus grows the box and nudges its neighbors; an outline draws outside the border edge without taking space, so nothing shifts. outline-offset pushes it out, or inside with a negative value, and the outline has followed border-radius in every major engine since Safari 16.4 (March 2023). The catch: outline: none removes the focus ring, so add a visible focus style of your own.
K. Typography and text
Leave out either the size or the family and the whole font declaration is invalid and silently dropped. Order matters too: style, variant and weight come before the size, /line-height right after it, and the family last. Because every omitted part resets, a font rule placed after line-height: 1.6 puts line height back to normal; set it inside the shorthand or after it.
Fallback works per character, not per page: if the first font has no glyph for, say, an emoji or a Chinese character, the browser looks further down the list for just that character. A generic family such as sans-serif or system-ui guarantees something usable at the end. Never quote a generic: "sans-serif" asks for a font literally named sans-serif, which no system has.
Any number from 1 to 1000 is valid (CSS Fonts 4), which variable fonts use for fine steps; normal is 400 and bold is 700. When a static font lacks the exact weight, a fixed search picks a nearby face, so 600 may render as 700. Relative keywords see only four weights: bolder turns 400 into 700 and 700 into 900, while lighter drops 400 to 100.
@font-face only registers a family name and its files; the browser fetches a face once some text needs it. font-display sets what happens meanwhile. block hides text for a short period, swap shows the fallback almost at once and replaces it whenever the file arrives, causing a visible reflow, and optional has no swap period, so a slow font is skipped for that page view.
letter-spacing and word-spacing add to the font's own spacing rather than replacing it, and any non-zero letter spacing turns off optional ligatures such as fi. text-indent touches only the first line unless you add each-line or hanging. text-align: justify stretches every line except the last. Arabic and other connected scripts look broken under letter spacing.
textContent and submitted form values keep the original casing, and CSS Text 3 says a plain-text copy must ignore the transform. One reader does not: innerText applies text-transform, so a script reading it gets the uppercased string. capitalize is not title case either; it raises the first letter of every word, articles included. Keep real casing in the HTML when it carries meaning.
pre keeps spaces and line breaks but never wraps, so a long line pushes past the box; pre-wrap keeps them and still wraps, the safer choice for user text. pre-line collapses runs of spaces yet honors newlines. Since white-space became a shorthand, nowrap is really text-wrap-mode: nowrap, and space handling lives in white-space-collapse. A tab renders 8 spaces wide by default.
text-overflow never causes overflow; it only marks text already cut off along the line direction of a block container whose overflow is not visible. So you need a width limit, a line that cannot wrap (white-space: nowrap or one very long word) and clipping. It ignores text spilling out the bottom, and a plain inline <span> cannot show it; make the element block or inline-block first.
The prefixed version works only as a trio: display: -webkit-box, -webkit-box-orient: vertical and -webkit-line-clamp: 3. Leave out the orient line and nothing clamps. Add overflow: hidden as well, or the extra lines stay visible beneath the ellipsis. The unprefixed line-clamp was not Baseline as of October 2026, and the legacy trio is specified to keep working.
vertical-rl runs lines top to bottom and stacks each new line to the left, so the block axis becomes horizontal. Physical width still measures across the screen, which now means the block dimension, while inline-size follows the text and maps to height. A component sized with width changes shape when the mode switches; one sized with logical properties keeps its proportions.
L. Color
Each short digit is repeated, so #f09 means #ff0099, not #f00990. The alpha pair runs from 00 (invisible) to ff (opaque): 80 is 128/255, about 50%, not 80%. The four-digit #rgba form expands the same way, while five or seven digits are invalid. Hex is case-insensitive and always describes sRGB, so it cannot reach wide-gamut colors.
Modern syntax separates channels with spaces, as in rgb(255 0 0 / 50%); the legacy form uses commas throughout. Mixing them, like rgb(255 0 0, 0.5), is invalid. rgba() and hsla() are now plain aliases. In HSL, 0deg is red, 120deg green and 240deg blue, and lightness 100% is white whatever the saturation, so two colors with equal L can look far from equally bright.
lab() and lch() use CIELAB, where L runs 0 to 100; oklch() uses Oklab, where L runs 0 to 1 (or 0% to 100%), a frequent source of typos. Hue angles do not carry across spaces: 0deg is red in hsl() but magenta in lch(), where red sits near 41deg, so copying a hue between functions shifts the color. All three reached Baseline in May 2023.
background: rgb(0 0 0 / 0.5) makes only the background translucent and leaves the text sharp. opacity: 0.5 fades the element as one flattened group, and no child can end up more opaque than its parent, because opacity applies to the whole subtree even though it is not inherited. Below 1 it also creates a stacking context. At opacity: 0 the element still takes clicks and focus.
border-color and an uncolored box-shadow already default to the color value, so borders match text for free; currentColor lets other properties opt in, such as fill: currentColor on an inline SVG icon that should follow link color. Used as the value of color itself, it takes the inherited color. A :hover that changes color recolors everything tracking it at once.
Percentages are normalized: in a 30%, b the second color gets 70%, and if two given percentages sum below 100% the result gains matching transparency. The space you name moves the midpoint. srgb mixes often look dull or dark, oklab spaces steps evenly, and polar spaces like oklch stay vivid but take the shorter way around the hue wheel by default. Under the current draft, two 0% values give a fully transparent, evenly weighted mix.
Current specs interpolate transparent in premultiplied alpha, so a gradient fading to it does not darken midway, though older engines treated it as black at zero alpha. Named colors are fixed sRGB values with a few surprises: green is #008000, while pure #00ff00 is called lime. currentColor is a keyword too, but it resolves per element instead of to one fixed value.
M. Transforms
Each function reads its arguments differently: translate(10px) moves only along x, scale(2, 0.5) stretches x and squashes y, scale(-1, 1) mirrors, and skew() slants by angles. A plain inline box such as an unstyled <span> cannot be transformed; give it display: inline-block first. Any transform other than none creates a stacking context and a containing block for fixed descendants.
Each function moves the coordinate system the next one uses. translateX(100px) rotate(45deg) slides right, then spins in place; rotate(45deg) translateX(100px) spins first, so the slide follows the rotated axis, diagonally. Read the list left to right with the element carrying its own grid, or right to left against a fixed page. The separate translate, rotate and scale properties spare you this.
The percentage resolves against the reference box, which for an HTML element is the border box: padding and border count, margins do not. Paired with position: absolute; top: 50%; left: 50%, whose insets measure the containing block, translate: -50% -50% centers a box of unknown size. The third, z value of translate takes only a length, never a percentage.
The initial value is 50% 50% 0 on HTML elements, so rotate() spins around the middle; transform-origin: top left swings the box like a door from its corner. SVG is the trap: most shapes inside an <svg> start at 0 0, the corner of the SVG coordinate system, so a rotated <rect> orbits that corner. Translation ignores the origin; rotate, scale and skew pivot on it.
They apply in a fixed order: translate, then rotate, then scale, then transform, whatever order you write them in. The syntax differs: the properties use spaces (translate: 10px 20px), the functions commas (translate(10px, 20px)). Being separate, a hover can change scale without wiping a translate set elsewhere. There is no skew property. Baseline since August 2022.
perspective on a parent sets how far the viewer stands from the z=0 plane: small values distort strongly, large ones barely. Children share one 3D space only if the parent has transform-style: preserve-3d, and some properties force it back to flat, including opacity below 1, any filter, or overflow other than visible or clip. The perspective() function affects only its own element.
Layout happens first, so neighbors are placed around the untransformed box even when it is scaled to double size or slid away, and a scaled card overlaps its siblings instead of pushing them. The transform still changes what getBoundingClientRect() reports and can enlarge the scrollable overflow area, so sliding something past the edge may add a scrollbar unless the container clips.
It is a hint, not a command: the browser may prepare a layer before the change, and each prepared layer holds memory. Its side effects are guaranteed, though: will-change: transform creates a stacking context and becomes the containing block for fixed-position descendants before anything moves. Switch it on shortly before an animation and off afterward, rather than leaving it in a stylesheet.
N. Transitions and animations
Leave out the property and all is assumed. Of two time values, the first written sets the duration and the next one the delay, so transition: opacity 0.2s 1s waits a second before a 0.2s fade, while transition: opacity alone runs for 0s and looks like no transition at all. Lists are comma-separated, and a later entry for one property overrides an earlier all for that property only.
Values that can be blended, like lengths, colors and transforms, transition smoothly. Discrete properties such as content have nothing in between, so by default a transition skips them and they flip at once; inside @keyframes they swap at the halfway point. display is the exception: animating to or from none keeps the visible value for the whole run. Mismatched types, such as height: 0 to height: auto, snap too.
ease starts slowly, speeds up sharply and coasts to a stop; ease-in-out is more symmetric. In cubic-bezier(x1, y1, x2, y2) both x values must stay within 0 to 1, but the y values may leave that range, which is how an overshoot or bounce is drawn. steps(4, end) holds each value for a quarter of the time and jumps at the end of each interval; step-start jumps immediately.
Leave out from or to and the element's current style fills that end, which makes return trips easy. A property missing from some stops interpolates between the stops where it appears. If two @keyframes share a name, only the last one counts; they never merge. Names are case-sensitive, and a declaration marked important inside a keyframe is ignored rather than winning.
Order mostly does not matter, with two exceptions. The first time is the duration and the second the delay, and any word that could be another value is read as that value first, so an animation named ease or infinite gets misparsed; put the name last. Omitted parts reset, animation-timeline included. Several animations go in one comma-separated list.
Without a fill mode the element snaps back to its normal styles when the animation ends. forwards keeps the last keyframe reached, which with alternate and an even count is the from frame, not to. backwards applies the first keyframe during the delay to avoid a flash; both does both. A filled value outranks normal declarations, so a later :hover rule cannot change it.
With alternate, odd passes run forward and even passes backward, and the backward passes reverse the easing too, so ease-in reads as ease-out on the way back. An even count therefore ends where it began. Counts may be fractional, so 0.5 stops halfway. infinite is accepted on a scroll-driven animation, but there it breaks the animation.
Changing left, width or margin forces layout and paint on every frame, and color skips layout but still repaints. When the element sits on its own layer, transform and opacity changes are handled in compositing alone, so the browser can run them off the main thread and stay smooth while scripts are busy. Move things with translate, not top.
Transitions never start on an element's first style, or when display changes from none, because there is no previous value. @starting-style supplies one. List display in the transition with allow-discrete so it flips at the start of a fade-in and the end of a fade-out. The @starting-style block has equal specificity, so place it after the rule it modifies. Baseline since August 2024.
The query mirrors an OS setting, and @media (prefers-reduced-motion) alone means reduce. It asks for less non-essential motion, not none: swap a slide or parallax for a fade or an instant change, and keep the state change itself. Writing motion inside (prefers-reduced-motion: no-preference) makes animation the opt-in, so a browser that cannot report the setting stays still.
O. Responsive design and container queries
The media type is optional, with all implied, though only requires one. A leading not negates its whole query, so not screen and (color) means not (screen with color), and in a comma list it reaches only its own query. Mixing and with or at one level is invalid without parentheses. Every feature test needs its own parentheses, as in (orientation: portrait).
Comparison operators read like math: (width >= 600px) equals (min-width: 600px), and <= and >= are inclusive like the prefixed forms. The gain is < and >, which exclude the edge, so (width < 600px) and (width >= 600px) split cleanly with no shared pixel. Range syntax has been Baseline since March 2023, when Safari 16.4 added it.
max-width: 600px and min-width: 600px both match at exactly 600px, so both rules apply there and the later one wins. The usual patch, min-width: 601px, leaves a gap: zoom and high-density screens can produce a 600.5px viewport that matches neither side. Use exclusive range syntax, (width < 600px) beside (width >= 600px), or build mobile-first so only one side needs a query.
Write the single-column layout with no query, then add min-width blocks that build up as space grows; each wider rule overrides only what changes, and small screens need no overrides at all. Desktop-first with max-width runs the other way, undoing wide styles step by step, which tends to pile up resets. Either way, keep breakpoints facing one direction so ranges never overlap.
Many phones lay pages out on a virtual canvas around 980px wide and shrink it to fit, so a max-width: 640px query never fires. <meta name="viewport" content="width=device-width"> sets the layout width to the screen's width in CSS pixels, which also defines vw. Avoid user-scalable=no and a low maximum-scale: blocking zoom fails low-vision users, and Safari has ignored both by default since iOS 10.
light also matches when the user has stated no preference, so dark styles belong in the dark branch. The query restyles only what you write; native controls, scrollbars and the page canvas stay light unless you also declare color-scheme: light dark, which is the setting light-dark() reads as well. In an SVG embedded with <img>, the query follows the embedding page's color scheme.
hover and pointer describe the primary input; any-hover and any-pointer ask whether any input qualifies, so a touchscreen laptop can report pointer: fine and any-pointer: coarse at once. Wrap hover-only effects in (hover: hover) so phones do not get stuck hover states, and enlarge tap targets under (pointer: coarse). prefers-contrast: more reflects a user setting, not hardware.
The container is an ancestor you mark with container-type; rules inside @container style its descendants, never the container itself, so a wrapper element is often needed. An unnamed query checks the nearest ancestor of the right type, and container-name targets a specific one. A card can then change layout in a sidebar or a main column with no viewport query. Baseline since February 2023.
Containment is the price: inline-size makes the browser size the element's inline dimension as if it had no children. A container meant to shrink-wrap its content, such as an inline-block or floated badge, can collapse toward zero width. size contains the block axis too, so without an explicit height it collapses vertically. Use inline-size on elements whose width comes from their parent.
cqw and cqh follow physical width and height, cqi and cqb the logical inline and block sizes, each 1% of the nearest container that can answer for that axis. A cqb needs a size container, since an inline-size container reports no block size. With no eligible container the unit falls back to the small viewport unit for its axis, so a stray cqi behaves like svi.
P. Overflow and scrolling
hidden clips but still makes a scroll container, so tabbing to a clipped input or calling scrollTo() can bring hidden content into view. clip clips with no scroll container at all, and focusable content past the edge gets focus but never scrolls into view, which strands keyboard users. scroll always shows scrollbars and auto only when needed. All values but visible and clip start a new block formatting context.
overflow-x: hidden with y left visible computes y to auto, so a box meant to clip sideways can sprout a vertical scrollbar. Writing overflow-y: clip avoids that: the current CSS Overflow draft leaves clip as written beside a scrollable axis, making a single-axis scroll container, and clip beside visible creates no scroll container at all.
It affects jumps to #anchors and script calls such as scrollIntoView(), never the user's own wheel or touch scrolling. Set it on the scroll container, or on html for the page; set on body, it does not reach the viewport. Browsers are allowed to ignore it. Consider limiting it to (prefers-reduced-motion: no-preference), since a long animated jump is motion the user did not start.
Given an axis but no strictness keyword, scroll-snap-type defaults to proximity, which may snap when scrolling ends close to a snap position; mandatory comes to rest on one whenever scrolling stops. A child with no scroll-snap-align is never a target. scroll-padding on the container keeps snapped items clear of a fixed or sticky header, and scroll-snap-stop: always keeps a fast swipe from skipping past an item.
Scroll chaining is when a modal's list hits its end and the page behind starts moving. contain stops that hand-off and other non-local actions such as pull-to-refresh, but keeps the local edge bounce; none removes the bounce as well. It applies only to scroll containers, so for an <iframe> you set it on that document's html and body. Two values set the x axis, then the y axis.
Classic scrollbars take space, so content jumps sideways the moment a page grows long enough to scroll. scrollbar-gutter: stable keeps that space even while nothing overflows, and both-edges mirrors it so centered content stays centered. Overlay scrollbars, common on touch devices, take no space, so no gutter appears there. Baseline since December 2024.
Q. Sizing and intrinsic sizes
When the three disagree the bounds win in a fixed order: width: 800px; max-width: 100% caps at the container, and adding min-width: 900px beats both. That makes max-width: 100% a safe default for images. All three measure the box chosen by box-sizing, so padding counts toward the cap only under border-box. Height follows the same order with min-height and max-height.
min-content wraps at every chance, so text shrinks to its longest word or widest image; max-content never soft-wraps, so a long sentence overflows rather than breaking. Both are measurements of the content that ignore the container, which makes width: max-content handy for tags and risky for paragraphs. Grid tracks accept the same keywords.
The keyword resolves to min(max-content, max(min-content, available space)): it shrink-wraps short content and wraps long content at the container edge, like a label that grows until it reaches the end of the row. It never drops below min-content, so one long word still overflows. Only the function form, such as fit-content(20ch), is valid in grid track lists.
A percent height needs the container's height to be set rather than driven by its content; otherwise it computes to auto. That is why height: 100% on a child of an auto-height <div> does nothing, and why full-height layouts once needed html, body { height: 100% }. Absolutely positioned elements are the exception. Widths rarely hit this, since a container width is usually known.
Set width, or let the element stretch, and aspect-ratio: 16 / 9 computes the height. At least one dimension must be automatic; give both an explicit size and the ratio is ignored. On images, aspect-ratio: auto 3 / 2 reserves 3:2 space before the file loads and switches to the real ratio after, avoiding a layout jump. The ratio applies to the box set by box-sizing.
With the root left at overflow: auto, vw is sized as if no scrollbar existed, so width: 100vw is wider than the space beside a classic scrollbar and the page scrolls sideways. 100% measures the containing block, which already excludes the scrollbar. Overlay scrollbars take no space, so the bug only shows where scrollbars are classic. A block element is full-width by default anyway.
R. Modern layer — custom properties, cascade layers, feature queries, nesting
Names start with -- and are case-sensitive, so --Brand and --brand are separate. The value is stored almost as written and only checked when var() drops it into a real property. var() works only inside property values, not in selectors, property names or media query conditions. Declaring on :root makes a value visible page-wide; declaring on a component scopes it there.
A Sass variable is replaced once, at build time. A custom property lives in the cascade: set --gap: 8px on .compact and every descendant reading var(--gap) changes, and a media query or a script can update it while the page runs. Each element resolves its own value, so one rule can produce different results in different subtrees. Registering it with @property can switch inheritance off.
The fallback applies when the variable is missing or invalid, and it runs to the closing parenthesis, so var(--font, Arial, sans-serif) falls back to the whole list. Chaining needs nesting: var(--a, var(--b, 1rem)). Writing var(--a, --b, 1rem) never tries --b; it falls back to the literal text --b, 1rem, which is invalid in most properties.
A bad literal like color: 16px is dropped while parsing, so an earlier color rule still applies. Substitution is different: the parser accepted color: var(--x) before learning --x holds 16px, so the declaration still wins the cascade, then fails, leaving the inherited or initial value. Registering --x as <color> with @property moves the failure into --x, which then takes its initial-value or, if allowed, inherits.
Unregistered custom properties animate discretely, so an angle stored in --angle jumps. Registering it with syntax: "<angle>" lets the browser interpolate. syntax and inherits are required, and initial-value is required unless the syntax is "*"; it must be computationally independent, so 10px works and 3em does not. Miss a required descriptor and the whole rule is ignored. Baseline since July 2024.
A layer's rank is fixed where its name first appears. Put @layer reset, base, components, utilities; before any block, and later blocks, or an @import with layer(base), only add rules to a slot that already exists. Without that statement the first block to mention a name sets its rank, which a library loaded early can do by accident. Anonymous layers cannot be reopened.
For normal declarations, layers rank from first declared, the weakest, to last, and unlayered styles sit above them all, so a p rule outside any layer beats .box p inside one. For declarations marked important the order flips: important rules in the first layer beat later layers and beat unlayered important rules. Wrapping a third-party stylesheet in an early layer lets ordinary selectors override it.
@supports asks whether the parser accepts a declaration, not whether every behavior behind it is complete, so test the exact value you rely on. not works on its own, but combining and with or without parentheses makes the whole rule invalid and ignored. selector(:has(a)) tests selector syntax. Keep the fallback outside the block and the enhancement inside it.
.card { .title {} } means .card .title, a descendant. To attach to the parent itself you need & with no space: &:hover targets the hovered card, while a bare :hover inside matches any hovered descendant. Sass-style concatenation does not exist, so &__title cannot build .card__title, and & cannot stand for a pseudo-element. Baseline since December 2023.
& counts as :is() of the whole parent list and takes its most specific member. Nest under #nav, .menu and every nested rule carries ID weight, even on elements matched only through .menu. A Sass build instead expands each parent separately. Keep parent lists uniform, or move the high-specificity selector out, to avoid rules that are oddly hard to override.
margin-inline covers the two ends a line of text runs between and padding-block the stacking direction, both mapped through writing-mode and direction. Under dir="rtl", margin-inline-start lands on the right; in vertical-rl it becomes the top. Physical margin-left ignores both. Logical names let a component survive translation into right-to-left or vertical scripts.
It governs what the browser paints for you: the canvas behind the page, form fields, scrollbars, and system colors such as Canvas. Without it, a dark theme can still show bright white inputs. color-scheme: light dark declares both are supported and the user's setting picks one, while only light also opts out of automatic dark modes. The property inherits.
S. Visual effects
Functions apply in the order written, and anything after drop-shadow() affects the shadow too, so drop-shadow(0 0 4px red) grayscale(1) grays the shadow while the reverse keeps it red. Unlike box-shadow, drop-shadow() traces the painted alpha, so a transparent PNG gets a shaped shadow, though it has no spread or inset. A filter other than none makes a containing block for fixed descendants.
Only pixels painted behind the element, up to its backdrop root, get filtered, and its own background must be partly transparent to reveal them. A backdrop root is the page root or any ancestor with a filter, opacity below 1, a mask, a clip-path or a mix-blend-mode, so a parent fading in with opacity cuts the frosted glass off from the page. Baseline since September 2024.
mix-blend-mode blends an element with whatever is painted beneath it in the same stacking context, which can unexpectedly include the page background. isolation: isolate on a wrapper limits the blend to that group. background-blend-mode stays inside one element, blending its image layers with each other and its background-color; its list repeats to match the images.
Layout keeps the full box, so siblings sit as if nothing were clipped, but clicks on the cut-away area no longer reach the element. Shapes measure the border box by default, and the clip also trims any box-shadow or outline beyond it. Any value but none creates a stacking context. Two shapes animate only when they match in type, such as polygons with the same number of points.
By default an image or gradient mask uses its alpha: opaque areas show the element and transparent areas hide it. An SVG <mask> defaults to luminance, so white reveals and black hides. Masking leaves geometry and hit-testing alone, so hidden parts still receive clicks. A mask that fails to load acts as transparent black and hides the whole element, and file:// URLs never load.
Below 1, the element and its descendants are painted as one layer and faded together, so no child can be more opaque than its parent and overlapping children do not show through each other. The new stacking context also traps z-index: a child with z-index: 999 cannot rise above siblings of its faded parent. Use a translucent color when only one surface should fade.
T. Lists, tables, counters and generated content
outside, the default, places the marker outside the item's box so wrapped lines align with the text; inside makes it the first inline content, so wrapped lines run back under it. ::marker can change the marker's color, font properties and content, but not padding or background. A list-style-image falls back to the list-style-type when the image cannot load.
It is a deliberate Safari behavior: a <ul> or <ol> with list-style: none loses its list role in the accessibility tree unless it sits inside <nav>, so screen readers stop announcing it as a list. Adding role="list" restores the semantics without changing the look. Non-empty generated content before each item also works, while content: "" does not count.
fixed takes effect only when the table has an explicit width; with width: auto the browser quietly uses the automatic algorithm. Column widths then come from <col> widths, otherwise first-row cells, otherwise an equal share, so the table can render as soon as row one arrives. The cost: a later cell too wide for its column overflows or clips instead of widening it.
In the collapsed model neighboring cells share one border, and conflicts follow a fixed ladder: hidden beats everything, none loses to anything, then the wider border wins, then style ranks from double and solid down to inset, then a cell's border beats its row's, column's and the table's. Collapsed tables also ignore border-radius and border-spacing.
Create a counter with counter-reset on an ancestor, bump it with counter-increment and print it with counter(name) in a ::before; counters(name, ".") prints nested numbers like 2.1.3. An element with display: none skips every counter operation. Since counter-reset is one property, two rules that each reset a different counter on the same element override each other; list both in one declaration.
::before and ::after default to content: normal, which computes to none, so without content nothing renders. Side-by-side strings concatenate with no operator, and attr(data-x) gives an empty string when the attribute is missing. Text after a / is alternative text for speech. Generated content is not in the DOM and some assistive tools skip it, so keep essential text in the HTML.
pointer-events: none lets clicks fall through to whatever lies underneath, but the element is still reachable with Tab, so it does not disable a link or button. The property inherits, and a child set back to auto receives events that then bubble through the parent's listeners. user-select: none only stops text selection; use it on controls, not on content people may want to copy.
U. Anchor positioning
The positioned element must be position: absolute or fixed, or the anchor is ignored. Naming an anchor inside anchor(--other top) positions against it without associating, so the element still has one default anchor. Association alone moves nothing either; tethering needs position-area or anchor() in an inset. If the anchor is display: none, the element falls back to its usual containing block.
The two leading dashes keep author names apart from present and future CSS keywords, the same reason custom properties use them. The name is still its own thing: anchor-name: --tip has no link to a --tip custom property or to an element's id, and only anchor lookups read it. Like every dashed ident it is case-sensitive, so --Tip and --tip name different anchors.
Names are document-wide by default, so when ten cards each declare anchor-name: --card and their tooltips sit outside them, every tooltip takes the tenth card as its anchor, and nothing reports an error. Either give each instance its own name, which usually means inline styles or generated names, or confine the name with anchor-scope on the component root. Implicit popover anchors avoid the clash entirely.
Set anchor-scope: --card, or all, on each repeated component's root. Inside that subtree the name is visible only to positioned elements in the same subtree, so each tooltip binds to its own card and the copies stop competing. The default is none. Because the scope is a subtree, the anchor and the element positioned against it must both sit under the scoping element.
Linking a popover to a button through popovertarget, commandfor, or showPopover() with a source option creates the anchor reference, and a <select> with appearance: base-select gets one for its picker. The reference only associates: position-area picks it up, but anchor() needs position-anchor: auto to opt in. To break the link, set position-anchor: none, since there is no anchor-name to remove.
position-area uses a 3×3 grid with the anchor as the center tile: top alone spans the whole top row, while top left takes one corner. It turns that region into the containing block and picks sensible default alignment. anchor(bottom) returns the anchor's bottom edge as a length for one inset, so you can adjust it with calc(). Mixing logical and physical keywords in position-area is invalid.
V. Scroll-driven animations
animation-timeline: scroll() or view() keeps your @keyframes but maps 0% to 100% onto scroll progress instead of seconds. Scrolling back runs the animation backward, and stopping the scroll freezes it as if paused. auto restores the time-based document timeline, while none leaves the animation with no timeline at all, so it never plays.
Every animation shorthand sets animation-timeline back to auto. Newer drafts let the shorthand carry a timeline value, but a browser that does not accept one there throws away the entire declaration. The reliable pattern is the shorthand first, then animation-timeline after it or in a more specific rule; otherwise the animation quietly plays on the clock.
Declare scroll-timeline-name: --page on the scroller and animation-timeline: --page on a descendant. Only descendants see the name by default; a sibling or cousin finds nothing, and an unmatched name leaves that animation with no timeline, so it stays put instead of running on time. timeline-scope on a shared ancestor widens the name's reach so other branches can use it.
Progress is a fraction of the scrollable distance, and a scroller whose content fits has a distance of zero. A scroll() timeline on an axis with no scrollable overflow is inactive, so the animation never advances, which looks like a broken selector. A box with overflow: hidden still counts when its content overflows. Check the axis you named, block by default, and that a narrow layout has not removed the overflow.
scroll() with no arguments means scroll(nearest block): the closest ancestor that is a scroll container in the block axis, an overflow: hidden box included. root picks the document's scroller and self the element's own, and the two arguments may come in either order. No name has to be declared, but moving the element into a different scroll container silently changes which progress it follows.
view() measures where the element itself sits inside its nearest scrolling ancestor, from first touching the scrollport's end edge to leaving past its start edge. Unlike scroll(), it has no argument for choosing another scroller. To animate one element by another's visibility, name the subject with view-timeline-name and use that name on the element you animate.
0% is when the subject first touches the scrollport's end edge and 100% when it has fully left past the start edge, so an effect keyed to to completes just as the element disappears. Put the finished state at a stop such as 50% or 80%, or shrink the range with view-timeline-inset or inset arguments to view(), so the timeline ends while the subject is still visible.
On a scroll or view timeline, progress comes from the scroll range, so animation-duration: 3s stretches nothing. The 1ms habit exists because Firefox needs a non-zero duration to apply the animation, and where scroll timelines are unsupported a 1ms run ends at once instead of playing for seconds on the clock. Pace the effect with keyframe stops or animation-range.
W. Scoped styles — @scope
In @scope (.card) to (.slot), rules match the card itself and its descendants but stop at any .slot: the slot and everything inside it are out of reach. That hole lets a component style its own markup without touching content passed into it. A selector list in either part creates several scopes at once. Baseline since March 2026.
Adding > * moves a bound one level down the tree. On the limit, to (.slot > *) makes the slot's children the stopping point, so the slot itself becomes stylable while its contents stay out. On the root, (.card > *) starts the scope at the card's children, which removes the card from scope. Use both to shift the whole donut down one level.
Each bare selector in a scope block is read as :where(:scope) img, and :where() contributes nothing, so the scope root adds no specificity. A scoped img therefore loses to an unscoped .thumb img, which surprises anyone expecting the scope to strengthen the rule. Engines and versions have treated & here differently, so test before relying on it.
:scope is an ordinary pseudo-class matching the scope root, so writing it adds 0-1-0 to whatever follows. :scope img weighs 0-1-1, enough to beat a plain .thumb, while a bare img in the same block stays 0-0-1. Write :scope explicitly when a scoped rule must win, and leave it out when scoped rules should stay easy to override.
With .light and .dark themes nested inside each other, a paragraph matched by both scoped p rules takes the one whose root is fewer generations up, whatever order the rules appear in. Proximity is checked late, after origin, importance, layers and specificity, so a more specific unscoped rule or a later layer still beats a nearer scope.
The limit stops selectors, not values. Set color: navy on the scope root and text inside an excluded .slot still turns navy through ordinary inheritance, because nothing in the scoped rule ever matched it. To keep slot content independent you have to set the inherited properties on the slot itself, through its own rules.
Every element a scoped selector matches must sit at or below the root, so combinators pointing outward fail: :scope + p and :scope ~ aside reach siblings outside the subtree and match nothing. Ancestors are a different case: the limit selector may mention them, as in to (.feature :scope figure), because that only describes context and never styles them.
Put @scope { img { ... } } inside a <style> element and its root becomes the element that contains that <style>. That keeps component styles beside their markup without inventing a class name for the root. You can still add a limit, as in @scope to (figure) { ... }, and :scope inside refers to that parent element.
X. Multi-column layout
column-count: 3 gives three columns at any width, even when each becomes too narrow to read. column-width: 15em asks for columns at least that wide and fits as many as possible, so the count rises on wide screens and falls on narrow ones, with widths stretching to fill the row. The one exception: in a container narrower than 15em, the single column shrinks to fit.
columns takes a count and a width in either order, since their values cannot be confused. When both are set and they conflict, the width wins: a container too narrow for 12 columns of 8em plus gaps gets fewer, and a wide one stops at 12 with each column growing past 8em. Read columns: 12 8em as no more than 12, no narrower than 8em.
With column-width: 8em and the default 1em gap, a second column needs at least 17em: two columns plus one gap. Below that, everything flows into one column and the gap disappears, and that column may be narrower than 8em. The switch is abrupt, so pick a width that suits your narrowest layout, or use a container query rather than relying on it.
With no height set, columns balance to roughly equal length. A fixed height or max-height caps each column, and content beyond it spills into extra columns past the container's edge, where balancing no longer applies. If the content fits, columns still balance unless you set column-fill: auto, which fills them one at a time and can leave the last ones empty.
getComputedStyle() reports normal for a multi-column gap rather than a pixel value, because the keyword becomes 1em only at layout. Flex and grid containers report normal too, and there it is used as 0. As 1em, the multicol gap grows with font-size. A column-rule is drawn in the middle of the gap without widening it, so a thick rule can crowd the text.
Y. Environment variables and env()
A custom property belongs to an element, inherits, and changes with your rules; an environment variable such as safe-area-inset-top comes from the browser or OS, has one value for the whole document, and no stylesheet can set it. Names are case-sensitive in both. Author-defined environment variables had not been specified as of October 2026, so env() reads only names the browser supplies.
var() belongs only inside property values. The spec lets env() stand in for any part of a value on any property, inside custom property values, and in at-rule descriptors, such as a media query condition, where var() cannot go. Descriptor support has lagged in browsers, so test that use before depending on it.
env(safe-area-inset-left, 1em 2em) falls back to the whole remainder, spaces and commas included, just like var(). A fallback can be another lookup, env(titlebar-area-height, env(safe-area-inset-top, 0px)), each with its own fallback. If the fallback contains a comma but the property does not accept one, the value becomes invalid.
Because env() is checked only after substitution, a typo like env(safe-area-inset-botom) passes parsing and wins the cascade over earlier rules. With no fallback the declaration then fails, and the property reverts to inheritance or its initial value, discarding the earlier rule you meant to keep. Always supply a fallback, which also covers browsers that lack that variable.
On a plain rectangular screen every safe-area-inset-* is 0, not missing, so a fallback never kicks in there; it applies only where the name is unknown. Padding of env(safe-area-inset-bottom, 1em) alone therefore gives desktop users none at all. Adding a base, as in calc(1em + env(safe-area-inset-bottom, 1em)), keeps the spacing everywhere and grows it near a notch or bar.
Browser toolbars that slide away while scrolling change safe-area-inset-* as they move, which can make padding tied to it jump. safe-area-max-inset-* reports the value its partner reaches with all retractable interface hidden and never changes, so layout stays still. Use the max inset for fixed chrome you want stable and the live inset for edges that should track the interface.
Keentune is not affiliated with or endorsed by the organizations whose documentation informs these maps.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.