This site is the corporate website of a company that makes party dresses and stage costumes. Because what we handle is “clothing for people who stand on stage,” we rebuilt the homepage as a theater: the first view is a closed donchō (theater curtain), and as you scroll, the curtain slowly rises to reveal the business content on the stage. This article summarizes the implementation approach for this opening effect, along with a position: sticky specification we got stuck on along the way. For the CSS behavior, we cite MDN’s official documentation as the source.

The Effect We Wanted

There were three requirements.

  1. The first view is a closed curtain. As you scroll, only the curtain rises, and the page does not advance during this time (we want the stage behind the curtain to appear in place).
  2. Once the curtain has fully risen, scrolling returns to normal and flows on to the next section.
  3. Do not hijack the scroll action itself. No so-called scroll-jacking that commandeers the wheel with preventDefault().

Structure — The Standard Pattern of sticky and Scroll Distance

This kind of scroll-linked effect (scrollytelling) has a standard pattern: fix the target of the effect in the viewport with position: sticky, and give its parent a height equal to “the scroll distance used for the effect.” The user is simply scrolling the page as usual, but while scrolling through the parent element, the target of the effect stays pinned to the screen and does not move, and only the scroll amount is consumed as the effect’s progress. Once past the parent, the pinning naturally releases and scrolling returns to normal.

<div data-curtain-track>
  <!-- The theater (stage + curtain). Pinned directly below the header -->
  <div class="sticky" style="top: var(--header-h)">
    <section class="stage">
      …the stage (business content cards, floor, lighting)…
      <div data-curtain>…the curtain (the wine-red drape)…</div>
    </section>
  </div>
  <!-- Scroll distance for opening the curtain -->
  <div style="height: 190svh"></div>
</div>

Progress is calculated from “how far the track has been scrolled through.” We designated the first 30svh (30% of the viewport height) as a “hold” during which the curtain does not move, then allocated 160svh over which it opens completely. For a one-time effect like a curtain rise, holding it back a little makes it feel more like a stage than starting too early.

// Progress from 0 to 1. The first 30svh is a hold, then it opens over 160svh
const progress = () => {
  const gone = headerH - track.getBoundingClientRect().top
  return Math.max(0, Math.min(1, (gone - innerHeight * 0.3) / (innerHeight * 1.6)))
}

Rather than tying the curtain’s movement directly to the scroll position, we interpose interpolation (lerp) that moves it a fixed fraction toward a target value each frame. Even with abrupt scrolling, the curtain follows with a soft delay, producing the inertia of heavy fabric.

current += (target - current) * 0.16
curtain.style.transform = `translateY(${-current * (curtain.offsetHeight + hemH)}px)`

Also, a user who has opened the curtain partway on their own can be assumed to want to see the rest, so once we detect that progress has exceeded 1/3, we carry the remainder automatically to the fully open position with scrollTo({ behavior: 'smooth' }). It only triggers in the forward direction, and becomes active again if the curtain is closed back down.

The Snag — sticky Does Not Pin with padding

At first, we secured the scroll distance with padding-bottom: 190svh on the parent element. But with this, sticky did not pin at all. Even though position: sticky and top were correctly applied as computed values, the element flowed away together with the scroll.

We checked, one by one, the usual causes of sticky not working — overflow: hidden on an ancestor, a transform on an ancestor, a missing top value — but none applied. Isolating further, we found that within the same parent, a short test element pinned without any problem. The only thing that did not pin was the tall element occupying the parent’s entire content.

The cause was the definition of the containing block. In MDN’s explanation of position, a sticky element is described as follows.

It’s treated as relatively positioned until its containing block crosses a specified threshold (such as setting top to value other than auto) within its flow root (or the container it scrolls within), at which point it is treated as “stuck” until meeting the opposite edge of its containing block.

(It is treated as relatively positioned, but once its containing block crosses a specified threshold — such as a top value other than auto — within its flow root (or the container it scrolls within), it is treated as "stuck," and remains so until it meets the opposite edge of its containing block.)

In other words, the range within which a sticky element can move is determined by the containing block. And the definition of the containing block is given in MDN’s explanation as follows.

If the position property is static, relative, or sticky, the containing block is formed by the edge of the content box of the nearest ancestor element that is either a block container (…) or establishes a formatting context (…).

(If the position property is static, relative, or sticky, the containing block is formed by the edge of the content box of the nearest ancestor element that is either a block container (or establishes a formatting context).)

The key is “the edge of the content box.” Unlike the containing block for absolute, which is formed by the padding box, a sticky element’s range of motion does not include the parent’s padding. In our structure, the sticky element (the theater) is itself all of the parent’s content, so the content box height equals its own height. There is zero slack in the range of motion, which is why it could not pin even by a single pixel. The reason the short test element pinned is that there was slack within the content box, and it can be explained by the same logic.

The solution is simple: stop using padding and secure the scroll distance with a real element.

<div data-curtain-track>
  <div class="sticky" style="top: var(--header-h)">…the theater…</div>
  <div style="height: 190svh"></div> <!-- a real spacer, not padding -->
</div>

Because the spacer is included in the parent’s content box, the sticky element can stay pinned for the length of that height.

Consideration for People Who Want Less Motion and Environments Where JS Is Disabled

Effects in which large elements move in sync with scrolling can be a trigger for discomfort in people with vestibular disorders. MDN’s explanation of prefers-reduced-motion also makes clear that this setting is a user’s expression of intent to “minimize non-essential motion.” We prepared the following fallbacks.

  • In environments with prefers-reduced-motion: reduce, we stop the curtain’s large movement and express opening and closing with an opacity fade according to progress (auto-scroll does not trigger either).
  • In environments where JS is disabled, <noscript> styles remove the curtain and spacer, showing the already-open stage with normal scrolling from the start.
  • Buttons and the like on the curtain that has fully risen and become invisible are set to inert, removing them from the targets of focus and clicks.

The Day We Can Write This in CSS Alone Is Near

Incidentally, scroll-linked animation is being standardized as CSS scroll-driven animations (animation-timeline: scroll()), and that is really where we would like to write it. However, MDN’s reference clearly states, as of July 2026, “Limited availability — This feature is not Baseline because it does not work in some of the most widely-used browsers.” Because we needed consistent behavior across all browsers and JS-side control according to progress (switching lerp, auto-scroll, and inert), we implemented it this time in plain JavaScript (requestAnimationFrame and IntersectionObserver).

Summary

  • We implemented an effect on the homepage that “fixes the theater with sticky and opens the curtain by the length of the scroll distance.” It does not hijack the scroll action.
  • A sticky element’s range of motion is determined by the containing block, and the containing block is formed by the edge of the parent’s content box. padding does not become scroll distance — secure the scroll distance with a real element.
  • We added a “hold” and lerp inertia to progress, and once the curtain is opened 1/3 of the way by the user, the rest is carried automatically.
  • With prefers-reduced-motion, we replace it with a fade, and when JS is disabled, we fall back to the already-open state.
  • Because CSS scroll-driven animations are not Baseline as of July 2026, we implemented it this time in JavaScript.

References