What Two

What Two "Separate" CSS Bugs Taught Me About Editing a Page

Leader 1 1 11
calendar_today agoschedule7 min read

The Border Was Never a Border: What Two "Separate" CSS Bugs Taught Me About Editing a Page I Thought I Knew

I spent an afternoon breaking my own homepage, and the useful part is not the fix. It is the order I did things in, which was wrong, and the checklist I now run before touching a page-level stylesheet.

This is written for anyone who maintains a site where the CSS lives in more than one place — a CMS with global styles plus per-page blocks, a theme plus overrides, a design system plus app-level patches. That is most of us.


The setup

My homepage has section dividers: thin horizontal rules between content blocks, tinted with a colour gradient. I set out to add a scroll reveal — content fading up as you scroll into it — using modern CSS:

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    @keyframes rise {
      from { opacity: 0; transform: translateY(14px) }
      to   { opacity: 1; transform: none }
    }
    .sect > .eyebrow, .sect > h2, .stats, .cta {
      animation: rise linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 25%;
    }
  }
}

Scroll-driven animations with no JavaScript, no IntersectionObserver, no library. Feature-gated behind @supports so older browsers get static content. Textbook progressive enhancement.

In the same pass, thinking about performance, I also added this to the section rule:

.sect:not(#projects) {
  content-visibility: auto;
  contain-intrinsic-size: auto 600px;
}

content-visibility: auto tells the browser to skip rendering work for off-screen content. Free performance. It is a well-regarded optimisation. I excluded one section because it is a scroll anchor target and I did not want the intrinsic-size placeholder fighting the jump.

Both changes shipped together. That detail matters later.


Two symptoms that looked like two bugs

Symptom one: the section dividers lost their colour. All of them went flat and dark — except one, which still showed the gradient and now looked out of place.

Symptom two: the scroll reveal did nothing. No fade, no rise. The page scrolled exactly as before.

I treated these as unrelated. That was the first real mistake, and it cost three rounds of fixes.

I reasoned about symptom one — containment suppresses the animation, remove content-visibility — shipped it, and asked whether it looked right. It did not. The dividers were still flat and the reveal was still dead.

At that point I had shipped a fix based on reasoning rather than measurement, and it had not worked. The correct move was to stop reasoning and go read something.


The thing I should have done in minute one

I finally opened the site's global stylesheet — the one I had not written that day and had not looked at — and searched it for the section class.

There it was:

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    section.sect:not([style]) { position: relative }
    section.sect:not([style])::before {
      content: "";
      position: absolute; top: -1px; left: 0; right: 0; height: 1px;
      background: linear-gradient(100deg, #C4B5FD, #93C5FD, #F0ABFC);
      transform-origin: left;
      animation: draw linear both;
      animation-timeline: view();
      animation-range: entry 8% entry 55%;
    }
  }
}
@keyframes draw {
  from { transform: scaleX(0); opacity: 0 }
  to   { transform: scaleX(1); opacity: 1 }
}

The coloured divider was never a border. It was a ::before pseudo-element carrying a gradient, drawn in from zero width by a scroll-driven animation that already existed on the site.

Which means symptom one and symptom two were never two bugs. They were one defect with two faces: scroll-driven timelines were not progressing on that page, so the reveal did nothing and every divider sat frozen at scaleX(0); opacity: 0.

And it retroactively explained the weird part. The one section still showing colour was the one I had excluded from content-visibility. I had accidentally run a controlled experiment and then misread the result — I looked at the one divider that was still working and filed it as the broken one.


The CSS lesson worth stealing

This is the part that generalises, and it is the reason the failure was silent:

animation: draw linear both;

animation-fill-mode: both means the element holds the from frame before the animation starts and the to frame after it ends. Combine that with a timeline that never advances, and the element is pinned at its first keyframe forever.

If your first keyframe is opacity: 0, the element is now invisible. Not unstyled. Not falling back to the underlying declaration. Invisible.

There is no console error. No warning. No failed network request. Nothing in DevTools shouts at you. The element is in the DOM, its rules are in the cascade, the computed styles look reasonable — and you see nothing.

Two practical consequences:

  1. A from { opacity: 0 } with fill: both is load-bearing. It is not decoration. If the timeline can fail, the content can vanish. Where the element must be visible regardless, animate toward the enhancement instead of away from it, or scope the opacity: 0 so a dead timeline degrades to "no animation" rather than "no content."
  2. @supports (animation-timeline: view()) does not protect you here. The guard proves the browser parses the property. It does not prove the timeline will advance. Feature detection and feature function are different claims, and @supports only ever gives you the first one.

Being honest about what I did not solve

I fixed the visible problem by removing the dependency. The divider now paints unconditionally, overriding the animated version:

.dhsea-page section.sect:not([style])::before {
  content: "";
  position: absolute; top: -1px; left: 0; right: 0; height: 1px;
  background: var(--visit-hue, linear-gradient(100deg, #C4B5FD, #93C5FD, #F0ABFC));
  animation: none !important;
  transform: none !important;
  opacity: 1 !important;
}

Borders confirmed back. But I want to be straight about the limits of this write-up:

I never established why the timelines stopped advancing. [UNVERIFIED] The content-visibility correlation is strong — it went in and out at exactly the right moments, and the one excluded section behaved differently from the other ten — but correlation on a single machine is not a spec citation, and I have not reproduced it in isolation. Three hypotheses are still live: browser version predating scroll-driven animation support; something in the page stripping the timeline; or the possibility that the global animation had been quietly dead for weeks and the dividers I "remembered" were a fainter static border-image underneath.

The three lines that would settle it, if you ever hit something similar:

CSS.supports('animation-timeline', 'view()')
getComputedStyle(el, '::before').animationTimeline
getComputedStyle(el, '::before').opacity

If line one is false, the whole @supports block never applied and you are looking at a support problem. If line one is true but line two is none, something is stripping the timeline. If two looks right and three reads 0, you have found a frozen animation.

Publishing the open question is the point. A debugging post that ends "and then I understood everything" is usually a post where someone stopped looking once the symptom went away.


The pre-flight checklist

This is what I actually changed about how I work.

1. Read the global stylesheet before editing a page-level block.
Not skim — search it for the selectors you are about to touch. My homepage's global styles contained an entire motion system I had forgotten about: gradient breathing on card icons, a hero background drift, a button underline sweep, icon clip-paths keyed to section:nth-of-type(), and the divider animation that caused all of this. Three of the session's defects trace to editing a page block in ignorance of what global CSS already did to the same elements.

2. Ship one change per pass when the changes touch the same elements.
The reveal and the content-visibility optimisation went out together. When it broke, I could not tell which one did it, and I spent a round finding out. Two unrelated-sounding changes to the same selectors is one change for debugging purposes.

3. Treat simultaneous symptoms as one defect until proven otherwise.
"The colours are gone" and "the animation is dead" sounded like two tickets. They were one. When two things break in the same deploy, the prior should be a shared cause, not a coincidence.

4. Know what renders each visual element before you change it.
I called it a border for three rounds. It was a pseudo-element. Everything I reasoned about border-image was reasoning about the wrong mechanism — correct logic applied to a false premise, which is the most expensive kind of wrong because it feels productive.

5. After one reasoned fix fails, stop reasoning and measure.
The second reasoned fix is almost never better than the first; it is the same inference with more confidence behind it. One getComputedStyle read would have beaten both of mine.

6. Verify against the real rendered page, not the cache and not the source.
CMS and CDN layers will happily serve you a cached copy of the thing you just changed. Confirm you are looking at the new bytes before you conclude your fix did not work. The green build is a claim about the source; the rendered DOM is the product.

7. Gate every write on a hash.
Every edit I made sent the content hash I had read along with the new content, so a change made elsewhere in between would reject the write instead of silently clobbering it. Optimistic concurrency is cheap when the API supports it, and the alternative is discovering a lost edit days later.

8. @supports proves parsing, not function.
Worth repeating because it is the trap that made this silent. If a feature failing would hide content, design the failure mode deliberately instead of trusting the guard.


The uncomfortable part

Every one of these defects was found by a person loading the page and looking at it. Not by a gate, not by a test, not by a check that reported green. The visual layer had no automated instrument capable of failing, so it had no instrument at all — and an instrument that cannot fail has never proved anything.

If your build pipeline has no step where something looks at the rendered page, you do not have a rendering test. You have a source-code test that you have been reading as a rendering test, and the difference only shows up on a day like this one.


Written up from a real afternoon on dhseadev.online. If you have hit content-visibility interfering with scroll-driven animations in a reproducible way, I would genuinely like to hear about it — I still do not have that one nailed down.

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Just completed another large-scale WordPress migration — and the client left this

saqib_devmorph - Apr 7

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelski - Mar 19

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelski - Apr 23

Sovereign Intelligence: The Complete 25,000 Word Blueprint (Download)

Pocket Portfolio - Apr 1
chevron_left
965 Points14 Badges
6Posts
2Comments
4Connections
DHSeaDev is the independent studio behind a run of Manifest V3 Chrome extensions and idle games — bu... Show more

Related Jobs

View all jobs →

Commenters (This Week)

4 comments
2 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!