Writing
Four CSS traps, and the one property family behind all of them
A mask that cancelled a 3D transform, a perspective on the wrong element, a header that ate its own menu, and a timeline measured against the wrong box. All four looked right in the state anyone would check first.
4 min read
Four bugs, found weeks apart, that turned out to be the same bug wearing different clothes. Each one rendered correctly in the state anybody would check first, and each one was invisible until something specific happened.
They are all grouping and containing-block properties. Once you know that, they stop being surprises and become a checklist.
One: a mask cancelled preserve-3d
The hero carries a globe built from CSS 3D transforms — a few hundred dots positioned on a sphere, turned by the compositor. It looked perfect at rest and collapsed to a flat sliver a quarter of the way through every rotation.
The mask that faded its edge was on the same element as transform-style: preserve-3d.
The fix is structural, not a value change: the mask needs its own element, and the 3D context needs its own.
Two: perspective applies to children, not to itself
Same drawing, next problem. perspective was on the element that also carried
the rotation, and it did nothing.
Before
After
Three: a header that ate its own mobile menu
This one came in with screenshots, and it was the best bug of the four.
At the top of the page the mobile menu opened correctly. Scrolled down, the same button produced a transparent strip with the page showing through and no links in it.
The header gets a backdrop-blur once you scroll. backdrop-filter,
filter and transform all make an element the containing block for its
position: fixed descendants. The overlay lives inside the header, so
inset: 0 resolved against a 60px bar instead of the viewport.
The surface is now a layer inside the header and the header itself carries no
filter. Two tests hold it: one opens the menu at scroll 2600 and asserts the
overlay fills the viewport, and one asserts the header's own transform,
filter and backdrop-filter all compute to none.
Four: a timeline measured against a box that never moves
The newest one. A scroll-driven stagger inside a horizontally scrolling table froze — the first rows landed past their range and the last sat permanently mid-fade, looking like a row that had failed to load.
overflow-x: auto makes overflow-y compute to auto as well. So that
container is a scroll container on both axes, and a view timeline is
measured against its nearest scrollport ancestor — which never scrolls
vertically. Progress was frozen at a single value forever.
Why the timeline moved rather than the overflow
Setting overflow: clip would fix the measurement and stop the table
scrolling, which is the one thing that container exists to do. The timeline
moved to a wrapper outside it instead. Named view timelines resolve up the
ancestor chain by tree scope, not by scroll container, so the rows still find
it by name.
The checklist this produced
All four failed silently. None threw, none warned, and all four rendered a page that looked fine in the first state anyone checks. So the rule is not "be careful", it is a question asked before a property goes on:
| Property | What it silently does |
|---|---|
| mask, filter, opacity | Groups — flattens any preserve-3d on the same element |
| overflow (non-visible) | Groups, and makes the element a scrollport for view timelines inside it |
| transform, backdrop-filter | Becomes the containing block for position: fixed descendants |
| perspective | Applies to children, never to the element itself |
Grouping and containing-block properties are this codebase's recurring foot-gun. They are now the first thing checked, and the checklist is in the contributor file rather than in anybody's memory.
Start here
Tell us what you are building
Or what is breaking, or what has to go faster. You will get a straight answer from an engineer who would do the work.