CSS Scroll Effects: 16 Examples Built With CSS Alone

Scroll effects used to mean an event listener and a throttle function. Progress bars, reveals, sticky headers and snapping now run on CSS alone, and with Firefox 159 (October 27, 2026) scroll-driven animations ship enabled in every major browser engine.

Below are 16 CSS scroll effects you can try and copy, followed by a guide to timelines, browser support, performance and accessibility.

By Bogdan Sandu · Updated October 28, 2026

Scroll progress timelines

Effects that follow how far a container has scrolled, built on scroll() or a named scroll-timeline. Each one sits in its own scroll frame, inside @supports and a no-preference reduced motion query, so older browsers and reduced motion users get a plain, readable layout.

Reading progress bar

  • animation-timeline: scroll()
  • scaleX
  • position: sticky

The quiet economics of a corner bakery

By Ines Marlow, 6 min read

The ovens at Harlow Street go on at 3:40, and by the time the first commuter walks past, forty loaves are already cooling on the rack by the window.

Flour is the cheap part. Rent, electricity for two deck ovens and the hours before sunrise make up most of what a sourdough costs to produce, which is why the price board barely moves when wheat does.

The owners tried a delivery app for one winter. Orders went up, margins went down, and the bread spent twenty minutes in a bag losing its crust. They switched it off in March.

What works instead is a short menu. Six breads, two pastries, and a soup on weekdays. Anything that does not sell out by two in the afternoon leaves the rotation within a month.

Regulars get a stamp card, and the stamp card gets them nothing but a free coffee on the tenth visit. Nobody seems to mind. The card is mostly an excuse to say hello.

At closing, the leftover loaves go to a shelter two streets over. It is the one line on the spreadsheet the owners never try to optimise.

Scroll inside the frame.

A sticky bar at the top of the article scales from 0 to 1 on the X axis as the frame scrolls. scroll() with no arguments picks the nearest scroll container, so the same rule works on a page with scroll(root) instead. Without support the bar is simply not shown.

View code
.sc-read {
  width: min(100%, 520px);
  height: 380px;
  overflow-y: auto;
  border-radius: 16px;
  background: #fbf7f0;
  color: #2b241c;
  box-shadow: 0 18px 40px rgba(43, 36, 28, .12);
}
.sc-read:focus-visible {
  outline: 3px solid #c2410c;
  outline-offset: 3px;
}
.sc-read__bar {
  display: none;
  position: sticky;
  top: 0;
  z-index: 1;
  height: 6px;
  background: #efe4d3;
}
.sc-read__fill {
  display: block;
  height: 100%;
  background: #c2410c;
  transform: scaleX(0);
  transform-origin: 0 50%;
}
.sc-read__doc {
  padding: 28px clamp(18px, 6%, 40px) 36px;
}
.sc-read__title {
  margin: 0 0 6px;
  font-family: "Fraunces", serif;
  font-size: clamp(22px, 5vw, 28px);
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -.01em;
  color: #2b241c;
}
.sc-read__by {
  margin: 0 0 20px;
  font-size: 13px;
  color: #6b5d4c;
}
.sc-read__p {
  margin: 0 0 14px;
  font-size: 15px;
  line-height: 1.65;
}
@keyframes sc-read-fill {
  to {
    transform: scaleX(1);
  }
}
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: scroll()) {
    .sc-read__bar {
      display: block;
    }
    .sc-read__fill {
      animation: sc-read-fill linear both;
      animation-timeline: scroll();
    }
  }
}

Header that shrinks and turns solid

  • scroll()
  • animation-range: 0 160px
  • position: sticky
Road winding along green cliffs above the sea

Rooms above the harbour

Twelve rooms, one long breakfast table.

Every room faces the water. The top floor has a reading nook in the eaves and the ground floor opens onto the garden.

Breakfast runs until eleven, because nobody on holiday should set an alarm. Bread comes from the bakery on the corner.

Check-in is from three. Bikes are free to borrow, and the coast path starts at the end of the street.

In winter the fire in the lounge is lit from four, and the kitchen will pack a lunch for the ridge walk if you ask the night before.

Scroll inside the frame.

The sticky header starts transparent over the photo with an oversized logo. A scroll() timeline limited to the first 160px of scroll fades in the white background and shadow, darkens the text and scales the logo down. The logo shrinks with transform, so the header height never changes and nothing below it reflows.

View code
.sc-hdr {
  width: min(100%, 560px);
  height: 380px;
  overflow-y: auto;
  border-radius: 16px;
  background: #fff;
  color: #1b2a3d;
  box-shadow: 0 18px 40px rgba(16, 34, 58, .14);
}
.sc-hdr:focus-visible {
  outline: 3px solid #1b4d89;
  outline-offset: 3px;
}
.sc-hdr__bar {
  position: sticky;
  top: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  height: 64px;
  padding: 0 20px;
  background-color: #fff;
  color: #10223a;
  box-shadow: 0 1px 0 #e3e8ef;
}
.sc-hdr__logo {
  font-family: "Fraunces", serif;
  font-size: 22px;
  font-weight: 700;
  letter-spacing: -.01em;
  transform-origin: 0 50%;
}
.sc-hdr__nav {
  display: flex;
  gap: clamp(10px, 3vw, 20px);
  font-size: 14px;
  font-weight: 600;
}
.sc-hdr__hero {
  position: relative;
  display: grid;
  align-items: end;
  height: 280px;
  background: #10223a;
}
.sc-hdr__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: .62;
}
.sc-hdr__copy {
  position: relative;
  padding: 20px;
  color: #fff;
}
.sc-hdr__title {
  margin: 0;
  font-family: "Fraunces", serif;
  font-size: clamp(24px, 6vw, 32px);
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -.01em;
  color: #fff;
}
.sc-hdr__lead {
  margin: 6px 0 0;
  font-size: 15px;
}
.sc-hdr__body {
  padding: 22px 20px 30px;
}
.sc-hdr__p {
  margin: 0 0 14px;
  font-size: 15px;
  line-height: 1.6;
}
@keyframes sc-hdr-solid {
  from {
    background-color: rgba(255, 255, 255, 0);
    color: #fff;
    box-shadow: 0 0 0 rgba(16, 34, 58, 0);
  }
  to {
    background-color: #fff;
    color: #10223a;
    box-shadow: 0 8px 22px rgba(16, 34, 58, .14);
  }
}
@keyframes sc-hdr-logo {
  from {
    transform: scale(1.25);
  }
  to {
    transform: scale(1);
  }
}
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: scroll()) {
    .sc-hdr__hero {
      margin-top: -64px;
      height: 344px;
    }
    .sc-hdr__bar {
      animation: sc-hdr-solid linear both;
      animation-timeline: scroll();
      animation-range: 0px 160px;
    }
    .sc-hdr__logo {
      animation: sc-hdr-logo linear both;
      animation-timeline: scroll();
      animation-range: 0px 160px;
    }
  }
}

Sideways gallery driven by vertical scroll

  • view-timeline
  • position: sticky
  • cqw in keyframes

Five stops, one long weekend

Keep scrolling down. The row slides sideways while it stays pinned.

  • Deep blue fjord between steep rocky cliffs

    Fjord ridge

    Friday, boat and ridge hike

  • Narrow road climbing through green highland slopes under cloud

    Highland pass

    Saturday, the slow road north

  • Granite cliff above a pine forest and a still river

    Granite valley

    Saturday, river picnic

  • Red sandstone rim lit by low evening sun

    Red rock rim

    Sunday, sunset walk

  • Yellow tents pitched in snow below white peaks

    Snow camp

    Monday, the cold way home

That is the whole route. Total driving time, about nine hours.

Scroll inside the frame.

A tall runway holds a sticky panel, and the runway is a named view timeline. While the panel is pinned (the contain range), the row translates left by its own width minus the panel width, written as calc(-100% + 100cqw). Without support the runway collapses and the row becomes an ordinary snap scroller.

View code
.sc-pan {
  width: min(100%, 620px);
  height: 380px;
  overflow-y: auto;
  border-radius: 18px;
  background: #151a24;
  color: #e8ecf3;
}
.sc-pan:focus-visible {
  outline: 3px solid #c6f432;
  outline-offset: 3px;
}
.sc-pan__intro {
  padding: 26px 24px 6px;
}
.sc-pan__title {
  margin: 0 0 6px;
  font-family: "Fraunces", serif;
  font-size: 24px;
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -.01em;
  color: #fff;
}
.sc-pan__text {
  margin: 0;
  font-size: 14px;
  line-height: 1.55;
  color: #b9c2d3;
}
.sc-pan__track {
  display: flex;
  gap: 14px;
  margin: 0;
  padding: 14px 24px 18px;
  list-style: none;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-padding-inline: 24px;
}
.sc-pan__card {
  flex: 0 0 min(230px, 72%);
  margin: 0;
  padding: 0;
  scroll-snap-align: start;
}
.sc-pan__img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 12px;
}
.sc-pan__name {
  margin: 10px 0 2px;
  font-weight: 700;
  color: #c6f432;
}
.sc-pan__meta {
  margin: 0;
  font-size: 13px;
  color: #b9c2d3;
}
.sc-pan__outro {
  padding: 10px 24px 30px;
}
@keyframes sc-pan-slide {
  to {
    transform: translateX(calc(-100% + 100cqw));
  }
}
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) and (width: 1cqw) {
    .sc-pan__runway {
      height: 1100px;
      view-timeline: --sc-pan block;
    }
    .sc-pan__sticky {
      position: sticky;
      top: 0;
      display: flex;
      align-items: center;
      height: 380px;
      overflow: clip;
      container-type: inline-size;
    }
    .sc-pan__track {
      width: max-content;
      overflow: visible;
      scroll-snap-type: none;
      animation: sc-pan-slide linear both;
      animation-timeline: --sc-pan;
      animation-range: contain 0% contain 100%;
    }
    .sc-pan__card {
      flex-basis: 230px;
    }
  }
}

Back to top button that waits its turn

  • scroll-state(scrollable: top)
  • scroll() fallback
  • position: sticky

Changelog

  1. v4.12 Oct 21

    Export to CSV keeps column order and hidden columns stay hidden.

  2. v4.11 Oct 7

    Keyboard shortcuts panel, opened with the question mark key.

  3. v4.10 Sep 23

    Dark theme follows the system setting by default.

  4. v4.9 Sep 9

    Comments now support mentions and resolve with one click.

  5. v4.8 Aug 26

    Faster search across archived projects.

  6. v4.7 Aug 12

    Recurring tasks can skip weekends.

  7. v4.6 Jul 29

    New calendar view with drag to reschedule.

  8. v4.5 Jul 15

    Two-factor sign in with any authenticator app.

  9. v4.4 Jul 1

    First public API, read only.

Scroll inside the frame.

The changelog is a scroll-state container, and @container scroll-state(scrollable: top) reveals the button once there is something above to scroll back to (Chrome and Edge 133+). Browsers with scroll timelines but no scroll-state queries fade it in over the first 120px instead, and everything else shows it all the time. The click scrolls the frame, not the page.

View code
.sc-top {
  width: min(100%, 460px);
  height: 380px;
  overflow-y: auto;
  padding: 24px 22px 0;
  border: 1px solid #2a3142;
  border-radius: 16px;
  background: #141925;
  color: #dfe5f0;
}
.sc-top:focus-visible {
  outline: 3px solid #f5b301;
  outline-offset: 3px;
}
.sc-top__title {
  margin: 0 0 14px;
  font-family: "Fraunces", serif;
  font-size: 24px;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -.01em;
  color: #fff;
}
.sc-top__list {
  margin: 0;
  padding: 0 0 28px;
  list-style: none;
}
.sc-top__item {
  margin: 0;
  padding: 12px 0 12px 16px;
  border-left: 2px solid #f5b301;
}
.sc-top__item + .sc-top__item {
  margin-top: 6px;
}
.sc-top__ver {
  display: flex;
  justify-content: space-between;
  margin: 0 0 2px;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 13px;
  font-weight: 700;
  color: #f5b301;
}
.sc-top__ver span {
  font-weight: 400;
  color: #a9b3c6;
}
.sc-top__txt {
  margin: 0;
  font-size: 14px;
  line-height: 1.5;
}
.sc-top__dock {
  position: sticky;
  bottom: 16px;
  height: 0;
}
.sc-top__btn {
  position: absolute;
  right: 0;
  bottom: 0;
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #f5b301;
  color: #1a1300;
  box-shadow: 0 8px 20px rgba(0, 0, 0, .35);
  cursor: pointer;
}
.sc-top__btn:hover {
  background: #ffc933;
}
.sc-top__btn:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 3px;
}
@keyframes sc-top-show {
  from {
    opacity: 0;
    visibility: hidden;
    transform: translateY(14px);
  }
  to {
    opacity: 1;
    visibility: visible;
    transform: none;
  }
}
@supports (container-type: scroll-state) {
  .sc-top {
    container-type: scroll-state;
  }
  .sc-top__btn {
    opacity: 0;
    visibility: hidden;
  }
  @container scroll-state(scrollable: top) {
    .sc-top__btn {
      opacity: 1;
      visibility: visible;
    }
  }
}
@media (prefers-reduced-motion: no-preference) {
  .sc-top {
    scroll-behavior: smooth;
  }
  @supports (container-type: scroll-state) {
    .sc-top__btn {
      transform: translateY(14px);
      transition: opacity .25s ease, transform .25s ease, visibility 0s .25s;
    }
    @container scroll-state(scrollable: top) {
      .sc-top__btn {
        transform: none;
        transition: opacity .25s ease, transform .25s ease;
      }
    }
  }
  @supports (animation-timeline: scroll()) and (not (container-type: scroll-state)) {
    .sc-top__btn {
      animation: sc-top-show linear both;
      animation-timeline: scroll();
      animation-range: 40px 160px;
    }
  }
}

View timelines: effects on enter

Here the timeline belongs to the element, not the scroller. view() tracks one card, photo or number as it crosses the frame, and animation-range picks the slice that matters.

Cards that rise in as they enter

  • animation-timeline: view()
  • animation-range: entry
  • fill-mode both

What the studio builds

Small team, six things we do well.

  • Marketing sites

    Fast pages your team can edit without a ticket.

  • Design systems

    Tokens, components and docs that stay in sync.

  • Search audits

    What blocks crawling, fixed in order of impact.

  • Dashboards

    Charts people read, not charts people scroll past.

  • Accessibility fixes

    Keyboard, contrast and screen reader passes.

  • Support retainers

    A fixed number of hours, answered within a day.

Scroll inside the frame.

Each card owns a view() timeline, so the reveal runs while that card enters the frame and finishes when it is fully in. The right-hand column starts 15% later into its entry, which reads as a stagger without a single delay value. The both fill mode holds the end state once a card is in view.

View code
.sc-rev {
  width: min(100%, 560px);
  height: 380px;
  overflow-y: auto;
  padding: 26px 22px;
  border-radius: 16px;
  background: #fff;
  color: #17312e;
  box-shadow: 0 16px 36px rgba(23, 49, 46, .1);
}
.sc-rev:focus-visible {
  outline: 3px solid #0f766e;
  outline-offset: 3px;
}
.sc-rev__title {
  margin: 0;
  font-family: "Fraunces", serif;
  font-size: 24px;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -.01em;
  color: #17312e;
}
.sc-rev__lead {
  margin: 4px 0 120px;
  font-size: 15px;
  color: #4a5d5a;
}
.sc-rev__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 14px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.sc-rev__card {
  margin: 0;
  padding: 16px;
  border: 1px solid #d6ebe7;
  border-radius: 14px;
  background: #f4fbf9;
}
.sc-rev__icon {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 10px;
  background: #ccf1ea;
  color: #0f766e;
}
.sc-rev__icon svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.sc-rev__name {
  margin: 12px 0 4px;
  font-family: inherit;
  font-size: 16px;
  font-weight: 700;
  letter-spacing: 0;
  color: #17312e;
}
.sc-rev__txt {
  margin: 0;
  font-size: 14px;
  line-height: 1.5;
  color: #3e524f;
}
@keyframes sc-rev-rise {
  from {
    opacity: 0;
    transform: translateY(36px) scale(.96);
  }
  to {
    opacity: 1;
    transform: none;
  }
}
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .sc-rev__card {
      animation: sc-rev-rise linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 100%;
    }
    .sc-rev__card:nth-child(even) {
      animation-range: entry 15% entry 100%;
    }
  }
}

Photos that sharpen and settle

  • view()
  • filter: blur()
  • transform: scale()

Field journal, issue 14

Three places where the water does the talking.

Waterfall dropping into a mossy green gorge
The falls after a week of rain, loud enough to end every conversation.
Granite cliff above a pine forest and a still river
Morning on the valley floor, before the first bus arrives.
Orange jellyfish drifting in deep blue water
A sea nettle at the aquarium, the one exhibit nobody walks past.

Scroll inside the frame.

Every photo starts blurred, desaturated and scaled up inside a clipped frame, then sharpens to normal by the time it is a third of the way across the frame (entry 0% to cover 40%). Neither filter nor transform triggers layout, and Chromium can hand both to the compositor.

View code
.sc-blur {
  width: min(100%, 520px);
  height: 400px;
  overflow-y: auto;
  padding: 26px 22px 8px;
  border: 1px solid #262b36;
  border-radius: 16px;
  background: #0f1218;
  color: #e6e9ef;
}
.sc-blur:focus-visible {
  outline: 3px solid #ffd27a;
  outline-offset: 3px;
}
.sc-blur__title {
  margin: 0;
  font-family: "Fraunces", serif;
  font-size: 26px;
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -.01em;
  color: #fff;
}
.sc-blur__lead {
  margin: 6px 0 40px;
  font-size: 15px;
  color: #aab2c0;
}
.sc-blur__fig {
  margin: 0 0 34px;
}
.sc-blur__frame {
  overflow: clip;
  border-radius: 12px;
  background: #1c212b;
}
.sc-blur__img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
.sc-blur__cap {
  margin: 10px 0 0;
  font-family: "Fraunces", serif;
  font-size: 15px;
  font-style: italic;
  line-height: 1.45;
  color: #c9cfda;
}
@keyframes sc-blur-in {
  from {
    filter: blur(12px) saturate(.3);
    transform: scale(1.18);
  }
  to {
    filter: none;
    transform: scale(1);
  }
}
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .sc-blur__img {
      animation: sc-blur-in linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 40%;
    }
  }
}

Highlighter that follows the center line

  • view()
  • animation-range: cover
  • background-size

Most redesigns fail for a boring reason. Nobody wrote down what the old site did well, so the new one quietly drops it.

Before the first sketch, list the five pages that bring in most of the leads. Those pages get changed last, and only with a test running.

Then read the support inbox for a week. The questions people ask are your real navigation, whatever the sitemap says.

Finally, keep one person responsible for words. Layouts survive committees. Copy written by five people reads like nobody wrote it.

Scroll inside the frame.

Each marked phrase has its own view timeline. A phrase sits on the middle of the scrollport at roughly cover 50%, so the swipe runs from cover 30% to cover 50% and the highlight completes as the phrase reaches the dashed line. The single-color background-image is only there to make background-size animatable. Without support the phrases are simply highlighted.

View code
.sc-hl {
  position: relative;
  width: min(100%, 540px);
  height: 380px;
  overflow-y: auto;
  border-radius: 16px;
  background: #fffdf6;
  color: #22201a;
  box-shadow: 0 16px 36px rgba(34, 32, 26, .1);
}
.sc-hl:focus-visible {
  outline: 3px solid #b45309;
  outline-offset: 3px;
}
.sc-hl__guide {
  position: sticky;
  top: 50%;
  z-index: 1;
  height: 0;
  border-top: 2px dashed #d9cfae;
  pointer-events: none;
}
.sc-hl__body {
  padding: 150px clamp(18px, 6%, 40px) 190px;
}
.sc-hl__p {
  margin: 0 0 26px;
  font-family: "Fraunces", serif;
  font-size: clamp(18px, 4.6vw, 22px);
  line-height: 1.5;
}
.sc-hl__mark {
  padding: 0 .1em;
  background-color: #ffe27a;
  color: inherit;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}
@keyframes sc-hl-swipe {
  to {
    background-size: 100% 100%;
  }
}
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .sc-hl__mark {
      background-color: transparent;
      background-image: linear-gradient(#ffe27a, #ffe27a);
      background-repeat: no-repeat;
      background-size: 0% 100%;
      animation: sc-hl-swipe linear both;
      animation-timeline: view();
      animation-range: cover 30% cover 50%;
    }
  }
}

Numbers that count up on entry

  • @property integer
  • counter()
  • view()

Crumbline in numbers

We pick up unsold bread from bakeries at closing time and take it to shelters before dinner. Scroll down for the last twelve months.

  • 98%pickups on time
  • 412partner bakeries
  • 36cities
  • 7 minaverage wait at the door

Scroll inside the frame.

A registered integer property, --sc-n, animates from 0 to each stat on a view() timeline, and counter-reset feeds it into a counter shown by ::after. Because the property is typed, the browser interpolates whole numbers instead of flipping at 50%. The real figure stays in the markup as visually hidden text, and it is the visible text wherever the timeline is unsupported.

View code
@property --sc-n {
  syntax: "<integer>";
  inherits: false;
  initial-value: 0;
}
.sc-num {
  width: min(100%, 540px);
  height: 380px;
  overflow-y: auto;
  padding: 26px 22px 150px;
  border-radius: 16px;
  background: #0e2a22;
  color: #e3f3ec;
}
.sc-num:focus-visible {
  outline: 3px solid #7ee2b8;
  outline-offset: 3px;
}
.sc-num__title {
  margin: 0;
  font-family: "Fraunces", serif;
  font-size: 24px;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -.01em;
  color: #fff;
}
.sc-num__lead {
  margin: 8px 0 170px;
  font-size: 15px;
  line-height: 1.6;
  color: #b8d4c9;
}
.sc-num__grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 22px 16px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.sc-num__item {
  margin: 0;
  padding-top: 12px;
  border-top: 2px solid #2c5a4b;
}
.sc-num__val {
  display: block;
  font-family: "Fraunces", serif;
  font-size: clamp(34px, 9vw, 46px);
  font-weight: 600;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  color: #7ee2b8;
}
.sc-num__label {
  display: block;
  margin-top: 6px;
  font-size: 14px;
  color: #b8d4c9;
}
.sc-num__val--a {
  --sc-to: 98;
  --sc-suffix: "%";
}
.sc-num__val--b {
  --sc-to: 412;
  --sc-suffix: "";
}
.sc-num__val--c {
  --sc-to: 36;
  --sc-suffix: "";
}
.sc-num__val--d {
  --sc-to: 7;
  --sc-suffix: " min";
}
@keyframes sc-num-count {
  from {
    --sc-n: 0;
  }
  to {
    --sc-n: var(--sc-to);
  }
}
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) and (content: "" / "") {
    .sc-num__val {
      counter-reset: sc-n var(--sc-n);
      animation: sc-num-count linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 40%;
    }
    .sc-num__val::after {
      content: counter(sc-n) var(--sc-suffix) / "";
    }
    .sc-num__txt {
      position: absolute;
      width: 1px;
      height: 1px;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }
  }
}

Snap and sticky

Scroll snapping and sticky positioning need no timeline at all and work in every current browser. The last two demos add scroll-state container queries, which style a snapped or stuck element in Chrome and Edge 133+ and leave a plain version elsewhere.

Full-height snap sections

  • scroll-snap-type: y mandatory
  • scroll-snap-stop: always
  • snap-align: start

Plan the week

Drag tasks onto days. Ledgerly warns you before a day goes over eight hours.

Track as you go

One timer, started from any task. Stop it from your phone on the way out.

Invoice on Friday

Tracked hours turn into a draft invoice. You check it, then send it.

Take the weekend

Notifications pause at six on Friday and come back on Monday morning.

Scroll inside the frame.

Each panel is exactly as tall as the frame and snaps to its top edge. scroll-snap-stop: always stops a fast flick from skipping a panel. Mandatory snapping suits a short tour like this one, while a long article is better served by proximity, which lets readers stop between sections.

View code
.sc-deck {
  width: min(100%, 520px);
  height: 360px;
  overflow-y: auto;
  scroll-snap-type: y mandatory;
  border-radius: 18px;
  box-shadow: 0 18px 40px rgba(12, 18, 40, .16);
}
.sc-deck:focus-visible {
  outline: 3px solid #1d3fd8;
  outline-offset: 3px;
}
.sc-deck__panel {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  height: 100%;
  padding: 28px;
  overflow: clip;
  scroll-snap-align: start;
  scroll-snap-stop: always;
}
.sc-deck__panel--a {
  background: #1d3fd8;
  color: #fff;
}
.sc-deck__panel--b {
  background: #ff8a5b;
  color: #2a1203;
}
.sc-deck__panel--c {
  background: #0f766e;
  color: #fff;
}
.sc-deck__panel--d {
  background: #f6d743;
  color: #2b2300;
}
.sc-deck__title {
  position: relative;
  z-index: 1;
  margin: 0 0 8px;
  font-family: "Fraunces", serif;
  font-size: clamp(28px, 7vw, 38px);
  font-weight: 600;
  line-height: 1.05;
  letter-spacing: -.02em;
  color: inherit;
}
.sc-deck__txt {
  position: relative;
  z-index: 1;
  max-width: 34ch;
  margin: 0;
  font-size: 15px;
  line-height: 1.55;
}
.sc-deck__shape {
  position: absolute;
  top: 26px;
  right: 26px;
  width: 120px;
  height: 120px;
  border: 14px solid currentColor;
  border-radius: 50%;
  opacity: .22;
}
.sc-deck__panel--b .sc-deck__shape {
  border-radius: 24px;
  transform: rotate(12deg);
}
.sc-deck__panel--c .sc-deck__shape {
  width: 150px;
  height: 75px;
  border-radius: 75px 75px 0 0;
  border-bottom: 0;
}
.sc-deck__panel--d .sc-deck__shape {
  border-radius: 0;
  border-width: 0 14px 14px 0;
  transform: rotate(45deg);
  width: 80px;
  height: 80px;
}

Stories with snap and segment bars

  • scroll-snap-type: x
  • named scroll timeline
  • scroll-state(snapped: x)

lowtide.trips 2h

  • Old canal houses with small boats moored along the water

    Arrived. The canal is quieter than the guidebook promised.

  • Narrow brick alley with fire escapes and city towers beyond

    Shortcut to the market. Smells like coffee and rain.

  • City skyline at sunrise seen from above

    Up at six for this. Worth it.

  • Road winding along green cliffs above the sea

    Rented bikes, followed the coast road until lunch.

  • Hiker with a large backpack looking over misty mountains

    Last day. Already planning the next one.

Swipe or scroll the story sideways.

Five full-width slides snap one at a time. The segment bars share the row scroll timeline through timeline-scope, and each bar fills over its own quarter of the timeline with animation-range: 25% 50% and so on. In Chrome 133+ the caption of the snapped slide fades in through a scroll-state(snapped: x) query. Without support the bars are hidden and every caption is visible.

View code
.sc-story {
  position: relative;
  height: 100%;
  overflow: hidden;
  background: #000;
  color: #fff;
  timeline-scope: --sc-story;
}
.sc-story__top {
  position: absolute;
  inset: 0 0 auto;
  z-index: 2;
  padding: 10px 12px 12px;
  background: rgba(0, 0, 0, .42);
  pointer-events: none;
}
.sc-story__bars {
  display: none;
  gap: 4px;
  margin-bottom: 10px;
}
.sc-story__seg {
  flex: 1;
  height: 3px;
  overflow: hidden;
  border-radius: 3px;
  background: rgba(255, 255, 255, .4);
}
.sc-story__seg i {
  display: block;
  height: 100%;
  background: #fff;
  transform: scaleX(0);
  transform-origin: 0 50%;
}
.sc-story__seg:first-child i {
  transform: none;
}
.sc-story__who {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0;
  font-size: 14px;
  font-weight: 700;
}
.sc-story__avatar {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border: 2px solid #fff;
  border-radius: 50%;
  background: #e8572a;
  font-size: 11px;
}
.sc-story__time {
  font-weight: 400;
}
.sc-story__track {
  height: 100%;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  scroll-timeline: --sc-story inline;
}
.sc-story__track:focus-visible {
  outline: 3px solid #fff;
  outline-offset: -5px;
}
.sc-story__list {
  display: flex;
  height: 100%;
  margin: 0;
  padding: 0;
  list-style: none;
}
.sc-story__slide {
  position: relative;
  flex: 0 0 100%;
  height: 100%;
  margin: 0;
  scroll-snap-align: start;
  scroll-snap-stop: always;
}
.sc-story__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.sc-story__cap {
  position: absolute;
  left: 14px;
  right: 14px;
  bottom: 22px;
  margin: 0;
  padding: 12px 14px;
  border-radius: 12px;
  background: rgba(0, 0, 0, .66);
  font-size: 15px;
  line-height: 1.4;
}
@keyframes sc-story-fill {
  to {
    transform: scaleX(1);
  }
}
@supports (container-type: scroll-state) {
  .sc-story__slide {
    container-type: scroll-state;
  }
  .sc-story__cap {
    opacity: 0;
  }
  @container scroll-state(snapped: x) {
    .sc-story__cap {
      opacity: 1;
    }
  }
}
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: scroll()) and (timeline-scope: --sc-story) {
    .sc-story__bars {
      display: flex;
    }
    .sc-story__track {
      scrollbar-width: none;
    }
    .sc-story__track::-webkit-scrollbar {
      display: none;
    }
    .sc-story__seg i {
      animation: sc-story-fill linear both;
      animation-timeline: --sc-story;
    }
    .sc-story__seg:nth-child(1) i {
      animation: none;
    }
    .sc-story__seg:nth-child(2) i {
      animation-range: 0% 25%;
    }
    .sc-story__seg:nth-child(3) i {
      animation-range: 25% 50%;
    }
    .sc-story__seg:nth-child(4) i {
      animation-range: 50% 75%;
    }
    .sc-story__seg:nth-child(5) i {
      animation-range: 75% 100%;
    }
  }
  @supports (container-type: scroll-state) {
    .sc-story__cap {
      transform: translateY(12px);
      transition: opacity .35s ease, transform .35s ease;
    }
    @container scroll-state(snapped: x) {
      .sc-story__cap {
        transform: none;
      }
    }
  }
}

Two-level sticky directory

  • position: sticky
  • stacked top offsets
  • z-index

A

  • AOAmara OseiProduct design
  • ALArne LindqvistBackend

B

  • BKBea KowalskiSupport lead
  • BNBram NieuwenhuisData
  • BSBilal ShahiOS

D

  • DMDara MoreauContent
  • DFDiego FernandesFrontend

K

  • KTKenji TanakaPlatform
  • KAKofi AsanteSecurity
  • KRKaia RuudResearch

M

  • MBMaren BergFinance
  • MIMalik IbrahimSales

S

  • SVSofia VargaAndroid
  • SPSam ParkDesign ops
  • SNSiddharth NairQA

Scroll inside the frame.

The search bar sticks at top: 0 and each letter heading sticks at top: 62px, right under it. Because every letter lives in its own group wrapper, a heading can only stay pinned while its group is on screen, and the next letter pushes it out. No scroll-state query and no script, so this works in every current browser.

View code
.sc-dir {
  width: min(100%, 440px);
  height: 380px;
  overflow-y: auto;
  border: 1px solid #dfe3f0;
  border-radius: 16px;
  background: #fff;
  color: #1e2235;
}
.sc-dir:focus-visible {
  outline: 3px solid #4338ca;
  outline-offset: 3px;
}
.sc-dir__search {
  position: sticky;
  top: 0;
  z-index: 2;
  height: 62px;
  padding: 10px 14px;
  background: #fff;
  border-bottom: 1px solid #e6e8f2;
}
.sc-dir__label {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.sc-dir__input {
  width: 100%;
  height: 42px;
  padding: 0 14px;
  border: 1px solid #c9cee0;
  border-radius: 10px;
  background: #f7f8fc;
  font: inherit;
  font-size: 15px;
  color: #1e2235;
}
.sc-dir__input::placeholder {
  color: #5f667c;
}
.sc-dir__input:focus-visible {
  outline: 3px solid #4338ca;
  outline-offset: 1px;
}
.sc-dir__letter {
  position: sticky;
  top: 62px;
  z-index: 1;
  margin: 0;
  padding: 6px 16px;
  background: #eef0ff;
  font-family: inherit;
  font-size: 13px;
  font-weight: 800;
  letter-spacing: .08em;
  color: #3730a3;
}
.sc-dir__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.sc-dir__row {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 0;
  padding: 10px 16px;
  border-bottom: 1px solid #f0f1f6;
}
.sc-dir__row[hidden],
.sc-dir__group[hidden] {
  display: none;
}
.sc-dir__av {
  display: grid;
  flex: 0 0 auto;
  place-items: center;
  width: 38px;
  height: 38px;
  border-radius: 50%;
  font-size: 13px;
  font-weight: 700;
  color: #1e2235;
}
.sc-dir__name {
  display: flex;
  flex-direction: column;
  font-weight: 600;
  line-height: 1.3;
}
.sc-dir__name small {
  font-size: 13px;
  font-weight: 400;
  color: #5f667c;
}

Toolbar and status bar that know they are stuck

  • container-type: scroll-state
  • scroll-state(stuck: top)
  • stuck: bottom

Spring menu notes

Edited by Rui and Hana, 4 minutes ago

Spring menu notes

Asparagus arrives in the second week of April. Keep the grilled version with brown butter, drop the soup, it never sold.

Lamb shoulder stays, but slower: six hours instead of four, finished on the grill for colour. Hana wants a green sauce with wild garlic.

New starter idea: radishes, cultured butter, flaky salt. Costs almost nothing, looks like the season.

Desserts: rhubarb tart on the menu from day one. The chocolate pot goes, regulars will complain for a week.

Wine list is Rui. Three new whites under forty, one orange wine by the glass.

Tasting for staff on the Monday before launch, 3 pm, everyone eats everything.

612 wordsSaved

Scroll inside the frame.

Both bars are sticky scroll-state containers. While the toolbar is pinned, @container scroll-state(stuck: top) gives it a shadow and slides the document name in, and the status bar shows its shadow only while it is stuck to the bottom. A container query styles descendants, so each bar wraps an inner element that takes the styles. Without support the bars stay flat.

View code
.sc-ed {
  width: min(100%, 520px);
  height: 380px;
  overflow-y: auto;
  border: 1px solid #e2ded6;
  border-radius: 16px;
  background: #fff;
  color: #262320;
}
.sc-ed:focus-visible {
  outline: 3px solid #b4471c;
  outline-offset: 3px;
}
.sc-ed__head {
  padding: 24px 22px 14px;
}
.sc-ed__title {
  margin: 0;
  font-family: "Fraunces", serif;
  font-size: 26px;
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -.01em;
  color: #262320;
}
.sc-ed__meta {
  margin: 4px 0 0;
  font-size: 13px;
  color: #6a6259;
}
.sc-ed__tools {
  position: sticky;
  top: 0;
  z-index: 2;
}
.sc-ed__bar {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 10px;
  min-height: 52px;
  padding: 6px 14px;
  border-top: 1px solid #ece8e1;
  border-bottom: 1px solid #ece8e1;
  background: #faf8f5;
}
.sc-ed__doc {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  font-weight: 700;
  font-size: 14px;
  white-space: nowrap;
  text-overflow: ellipsis;
  visibility: hidden;
}
.sc-ed__btns {
  display: flex;
  gap: 4px;
}
.sc-ed__btn {
  display: grid;
  place-items: center;
  width: 38px;
  height: 38px;
  padding: 0;
  border: 0;
  border-radius: 8px;
  background: transparent;
  font: inherit;
  font-size: 16px;
  color: #262320;
  cursor: pointer;
}
.sc-ed__btn:hover {
  background: #efebe4;
}
.sc-ed__btn[aria-pressed="true"] {
  background: #262320;
  color: #fff;
}
.sc-ed__btn:focus-visible {
  outline: 3px solid #b4471c;
  outline-offset: 1px;
}
.sc-ed__body {
  padding: 18px 22px 8px;
}
.sc-ed__p {
  margin: 0 0 14px;
  font-size: 15px;
  line-height: 1.65;
}
.sc-ed__foot {
  position: sticky;
  bottom: 0;
  z-index: 2;
}
.sc-ed__status {
  display: flex;
  justify-content: space-between;
  padding: 10px 16px;
  border-top: 1px solid #ece8e1;
  background: #faf8f5;
  font-size: 13px;
  color: #5c554d;
}
@supports (container-type: scroll-state) {
  .sc-ed__tools,
  .sc-ed__foot {
    container-type: scroll-state;
  }
  .sc-ed__doc {
    opacity: 0;
  }
  @container scroll-state(stuck: top) {
    .sc-ed__bar {
      background: #fff;
      box-shadow: 0 10px 22px rgba(38, 35, 32, .12);
    }
    .sc-ed__doc {
      visibility: visible;
      opacity: 1;
    }
  }
  @container scroll-state(stuck: bottom) {
    .sc-ed__status {
      background: #fff;
      box-shadow: 0 -10px 22px rgba(38, 35, 32, .1);
    }
  }
}
@media (prefers-reduced-motion: no-preference) {
  @supports (container-type: scroll-state) {
    .sc-ed__bar,
    .sc-ed__status {
      transition: box-shadow .25s ease, background-color .25s ease;
    }
    .sc-ed__doc {
      transform: translateY(6px);
      transition: opacity .25s ease, transform .25s ease;
    }
    @container scroll-state(stuck: top) {
      .sc-ed__doc {
        transform: none;
      }
    }
  }
}

Scroll behavior and containment

Properties that change how scrolling itself behaves: where an anchored heading lands, whether a panel hands its scroll to the page, and whether a jump animates or snaps straight there.

Anchored headings clear a fixed header

  • scroll-margin-top
  • scrollIntoView
  • aria-pressed toggle

Install

Add the CLI with your package manager, then run the init command in the project root. It writes a config file and nothing else.

npm install -D shipyard
npx shipyard init

Configure

The config names your build command and output folder. Environment values stay in your host, never in the file.

Preview builds are on by default for every pull request.

Deploy

Each deploy uploads the output folder as an immutable release. The live alias moves only after the health check passes.

Failed checks leave the previous release live, and the log shows which route failed.

Roll back

Point the alias at any earlier release. It takes a second because nothing is rebuilt.

Releases older than ninety days are pruned unless pinned.

Pick a section, then switch the margin off and pick it again.

The dark header overlaps the docs the way a fixed site header does. Every heading carries scroll-margin-top: 72px, so a jump lands it 16px below the header instead of underneath it. Turn the margin off and jump again to see the problem it solves. The links scroll only this frame, and focus moves to the heading.

View code
.sc-doc {
  width: min(100%, 560px);
}
.sc-doc__controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 12px;
}
.sc-doc__toc {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.sc-doc__link {
  display: inline-flex;
  align-items: center;
  min-height: 34px;
  padding: 0 12px;
  border: 1px solid #cfd6e4;
  border-radius: 999px;
  background: #fff;
  font-size: 14px;
  font-weight: 600;
  color: #1f3a8a;
  text-decoration: none;
}
.sc-doc__link:hover {
  border-color: #1f3a8a;
}
.sc-doc__link:focus-visible,
.sc-doc__toggle:focus-visible {
  outline: 3px solid #1f3a8a;
  outline-offset: 2px;
}
.sc-doc__toggle {
  min-height: 34px;
  padding: 0 12px;
  border: 1px solid #1d2433;
  border-radius: 8px;
  background: #1d2433;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12px;
  color: #fff;
  cursor: pointer;
}
.sc-doc__toggle[aria-pressed="false"] {
  background: #fff;
  color: #1d2433;
  text-decoration: line-through;
}
.sc-doc__frame {
  position: relative;
  overflow: hidden;
  border: 1px solid #d9deea;
  border-radius: 14px;
  background: #fff;
}
.sc-doc__header {
  position: absolute;
  inset: 0 0 auto;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 56px;
  padding: 0 18px;
  background: rgba(17, 22, 34, .94);
  color: #fff;
}
.sc-doc__brand {
  font-weight: 700;
}
.sc-doc__ver {
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12px;
  color: #c5cbe0;
}
.sc-doc__scroll {
  height: 320px;
  overflow-y: auto;
  padding: 72px 20px 140px;
  color: #2a2f3c;
}
.sc-doc__scroll:focus-visible {
  outline: 3px solid #1f3a8a;
  outline-offset: -3px;
}
.sc-doc__h {
  margin: 0 0 8px;
  padding-top: 4px;
  font-family: "Fraunces", serif;
  font-size: 21px;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -.01em;
  color: #111622;
  scroll-margin-top: 72px;
}
.sc-doc__h:focus {
  outline: none;
}
.sc-doc__h:focus-visible {
  outline: 2px solid #1f3a8a;
  outline-offset: 2px;
}
.sc-doc--off .sc-doc__h {
  scroll-margin-top: 0;
}
.sc-doc__p {
  margin: 0 0 12px;
  font-size: 15px;
  line-height: 1.6;
}
.sc-doc__code {
  margin: 0 0 22px;
  padding: 12px 14px;
  overflow-x: auto;
  border-radius: 8px;
  background: #f1f3f8;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 13px;
  line-height: 1.6;
  color: #1d2433;
}
@media (prefers-reduced-motion: no-preference) {
  .sc-doc__scroll {
    scroll-behavior: smooth;
  }
}

Panels that keep their scroll to themselves

  • overscroll-behavior: contain
  • scroll chaining
  • nested scrollers

Inbox overview. The page keeps going below the panels.

overscroll-behavior: auto

  • Invoice 2041 was paid
  • Mira commented on Homepage v3
  • New sign in from Lisbon
  • Backup finished at 02:00
  • Storage at 80 percent
  • Theo shared Q3 budget
  • Domain renews in 9 days
  • Weekly report is ready

overscroll-behavior: contain

  • Hana: menu draft is up
  • Rui: can we move tasting?
  • Hana: Monday works
  • Theo: invoice sent
  • Mira: new logo files
  • Rui: wine order placed
  • Hana: thanks, all
  • Theo: see you Monday

Below the panels: account settings, billing history and the team list. Scroll here to move the page on purpose.

Last sync two minutes ago.

Scroll each panel to its end and keep going.

The frame plays the page, and both panels scroll inside it. Scroll the left panel past its end and the extra scroll chains to the page behind it. The right panel has overscroll-behavior: contain, so it stops at its edge and the page stays put. On touch devices contain also stops the bounce from reaching the page.

View code
.sc-ovs {
  width: min(100%, 580px);
  height: 380px;
  overflow-y: auto;
  padding: 20px;
  border-radius: 16px;
  background: #eef1f6;
  color: #1f2533;
  box-shadow: inset 0 0 0 1px #d8dde8;
}
.sc-ovs:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 3px;
}
.sc-ovs__intro,
.sc-ovs__more {
  margin: 0 0 16px;
  font-size: 15px;
  line-height: 1.55;
}
.sc-ovs__more {
  margin: 18px 0 120px;
}
.sc-ovs__more + .sc-ovs__more {
  margin-top: 0;
}
.sc-ovs__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 14px;
}
.sc-ovs__panel {
  padding: 12px;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 6px 18px rgba(31, 37, 51, .08);
}
.sc-ovs__panel--contain {
  box-shadow: 0 6px 18px rgba(31, 37, 51, .08), inset 0 0 0 2px #2563eb;
}
.sc-ovs__tag {
  margin: 0 0 8px;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12px;
  font-weight: 700;
  color: #1d4ed8;
}
.sc-ovs__list {
  height: 170px;
  margin: 0;
  padding: 0;
  overflow-y: auto;
  list-style: none;
  border-radius: 8px;
}
.sc-ovs__list:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}
.sc-ovs__panel--contain .sc-ovs__list {
  overscroll-behavior: contain;
}
.sc-ovs__row {
  margin: 0;
  padding: 11px 10px;
  border-bottom: 1px solid #eceff5;
  font-size: 14px;
}

Smooth jumps that respect reduced motion

  • scroll-behavior: smooth
  • prefers-reduced-motion
  • scroll-snap-type: x

Tidepool, year by year

scroll-behavior:

  1. 2019

    Two friends ship a notes app for tide tables and nothing else.

  2. 2020

    Offline sync, because the beach has no signal.

  3. 2021

    Shared notebooks for surf clubs and sailing schools.

  4. 2022

    Apple Watch app with a single tide complication.

  5. 2023

    First paid plan. Ten thousand teams in the first year.

  6. 2024

    Rewritten sync engine, conflicts down by two thirds.

  7. 2025

    Map layer with live buoy data from public feeds.

  8. 2026

    Open source tide engine, released under MIT.

The year buttons call scrollIntoView on a card, and the row decides how to get there. scroll-behavior: smooth is set only inside a no-preference media query, so visitors who ask for reduced motion jump instantly. The label above the row reads the same media query through generated content, which makes the current mode visible.

View code
.sc-yr {
  width: min(100%, 600px);
  padding: 22px;
  border: 1px solid #2b2f3d;
  border-radius: 18px;
  background: #161923;
  color: #e8e9ef;
}
.sc-yr__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 12px;
}
.sc-yr__title {
  margin: 0;
  font-family: "Fraunces", serif;
  font-size: 22px;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -.01em;
  color: #fff;
}
.sc-yr__mode {
  margin: 0;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12px;
  color: #ff9e80;
}
.sc-yr__mode::after {
  content: "auto (reduced motion)";
}
.sc-yr__nav {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 14px 0;
}
.sc-yr__btn {
  min-width: 56px;
  min-height: 36px;
  padding: 0 12px;
  border: 1px solid #3a3f50;
  border-radius: 999px;
  background: transparent;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  color: #e8e9ef;
  cursor: pointer;
}
.sc-yr__btn:hover {
  border-color: #ff9e80;
}
.sc-yr__btn[aria-current="true"] {
  border-color: #ff7a59;
  background: #ff7a59;
  color: #1c0d07;
}
.sc-yr__btn:focus-visible,
.sc-yr__track:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 2px;
}
.sc-yr__track {
  display: flex;
  gap: 12px;
  margin: 0;
  padding: 0 0 12px;
  overflow-x: auto;
  list-style: none;
  scroll-snap-type: x proximity;
}
.sc-yr__card {
  flex: 0 0 min(210px, 75%);
  margin: 0;
  padding: 16px;
  border-radius: 14px;
  background: #222634;
  scroll-snap-align: start;
}
.sc-yr__year {
  display: block;
  font-family: "Fraunces", serif;
  font-size: 30px;
  font-weight: 600;
  line-height: 1;
  color: #ff9e80;
}
.sc-yr__txt {
  margin: 10px 0 0;
  font-size: 14px;
  line-height: 1.5;
  color: #c9ccd8;
}
@media (prefers-reduced-motion: no-preference) {
  .sc-yr__track {
    scroll-behavior: smooth;
  }
  .sc-yr__mode::after {
    content: "smooth";
  }
}

What are CSS scroll effects?

The label covers any front-end technique where CSS properties alone change an element’s position, appearance or snapping behavior as the page scrolls. No JavaScript scroll listeners involved.

Some of these change how a scroll container behaves: position: sticky, scroll snap, scroll-behavior and overscroll-behavior. The other group is scroll-driven animations, where animation-timeline ties ordinary keyframes to scroll position instead of time.

What falls outside is any effect where a script reads the scroll position on every event and moves things by hand. A scroll listener written in JavaScript can fake a sticky header or parallax just fine, it simply doesn’t count here.

The Firefox Source Docs point out that scroll-linked effects built on listeners run on the main thread and lag behind asynchronous scrolling, and that a lot of them can be rewritten in CSS so the browser keeps them in step with the scroll instead.

So the rule of thumb is this. If the browser is driving the effect, it counts, and if your own script is, it doesn’t.

Which CSS properties create scroll effects?

Four of them work without any animation at all: scroll-behavior, scroll-snap-type, position: sticky and overscroll-behavior. Everything animated goes through animation-timeline.

PropertyEffectJavaScript neededTypical use
scroll-behavior: smoothAnimated jumps to anchors and scripted scrollsNoIn-page navigation
scroll-snap-type, scroll-snap-alignScrolling stops at defined positionsNoCarousels, full-screen sections
position: stickyElement pins at an offsetNoHeaders, sidebars, table headings
overscroll-behaviorBlocks scroll chaining and bounceNoModals, chat panels
animation-timelineAnimation progress follows scrollNoProgress bars, reveals, parallax

Snapping and smooth scrolling

scroll-snap-type goes on the scroll container and takes an axis (x, y, block, inline or both) plus an optional strictness keyword, as MDN documents.

Set it to mandatory and the container has to settle on a snap position whenever scrolling stops. With proximity, the browser makes that call itself, depending on where the scroll ended.

scroll-snap-align goes on the children and accepts start, end, center or none. Under mandatory snapping, MDN warns that a child with the value none (or left unset) can never be scrolled into view.

scroll-behavior: smooth animates anchor jumps and scripted scrolls. It won’t smooth out a mouse wheel, which catches a surprising number of people.

Sticky positioning

A sticky element behaves like a relative one until the scroll reaches its offset, then it pins.

It needs an offset such as top: 0, because without one nothing sticks, and a scroll container to pin inside, usually the page itself. It also needs room in its parent, since the element stops at the parent’s edge.

That last rule trips people up. Put a sticky navigation bar inside a short wrapper and it releases the moment the wrapper ends. I’ve seen this eat a whole afternoon.

Overscroll and scroll chaining

The overscroll-behavior properties have worked in Chrome, Firefox and Safari since September 2022 (Safari 16). In April 2026, though, web-features, the data behind MDN’s Baseline labels, withdrew their Baseline status, because browsers disagree on scroll containers that have no overflow.

Set overscroll-behavior: contain on a modal body or chat panel and reaching its end no longer scrolls the page behind it. It also turns off pull-to-refresh, and the value none goes further by removing the overscroll glow or bounce as well.

How do scroll-driven animations work?

Instead of a clock, the animation reads scroll position. You write ordinary keyframes, then point animation-timeline at a scroll progress timeline or a view progress timeline.

The browser maps scroll distance to animation progress, so there is no listener to throttle and no frame loop to run.

Scroll progress timeline

Progress here means how far a scroll container has scrolled along one axis, 0% at the start and 100% at the end (Chrome for Developers).

The scroll() function builds one inline, and it takes a scroller and an axis. The default scroller is nearest, the closest ancestor scroll container. Use root when you want the document viewport, or self if the element itself scrolls.

The axis is block, inline, x or y, so animation-timeline: scroll(root block) is the standard form for a page-level progress bar. Declare it after the animation shorthand, because the shorthand resets the timeline.

To share one timeline between several elements, name it with scroll-timeline-name and a dashed identifier such as –hero-scroll, then reference that name in animation-timeline.

View progress timeline

Where the scroll version watches the whole scroller, a view progress timeline watches a single element.

The view() function links progress to the element’s position inside its scroll container, so the animation starts as the element enters and finishes as it leaves.

Every list item can carry its own view() timeline. Staggered reveals then need no delay math in script, and view-timeline-name works the same way scroll-timeline-name does when elements need to share one.

Controlling start and end with animation-range

animation-range chooses which slice of the timeline the animation covers.

MDN’s list of timeline range names runs through cover, contain, entry, exit, entry-crossing, exit-crossing and scroll. The table below covers the four that come up most.

Range nameStarts whenEnds when
coverElement begins to enter the scrollportElement has fully left (the default)
containElement is fully contained by the scrollport, or fully covers itElement is no longer fully contained by or fully covering the scrollport
entryElement begins to enterElement has fully entered
exitElement begins to leaveElement has fully left

Percentages inset a range. A form MDN lists, animation-range: entry 10% exit 100%, starts 10% into the entry phase and runs to the end of the exit phase.

For an element taller than the scrollport, contain still has something to track. It runs from the moment the element first completely covers the scrollport to the moment it no longer does.

How to build common scroll effects with CSS

Most real pages get by with a reading progress bar, a reveal on scroll, parallax layers and a shrinking sticky header. Each one sticks to a single timeline type and needs only a few lines of CSS.

Reading progress bar

Point the timeline at scroll(root block) and animate transform from scaleX(0) to scaleX(1), with transform-origin set to the left edge. Fix the bar to the top of the page.

The bar is a plain element, so any progress bar styled with CSS works as the base. Animate transform, not width, because width forces layout on every frame.

Reveal on scroll

  1. Write keyframes that run from opacity 0 and translateY(24px) to opacity 1 and translateY(0).
  2. Apply them with animation: reveal linear both, where the both fill mode keeps the start and end states.
  3. Add animation-timeline: view() after the shorthand.
  4. Limit the slice with animation-range: entry, so the reveal finishes as the element finishes entering.
  5. Wrap the whole rule in @supports (animation-timeline: view()).

Step 5 gets skipped a lot. Without it, a browser that ignores the timeline applies only half the effect, and the animation plays once on the normal clock at page load instead of following the scroll. Inside @supports, unsupported browsers just show plain, static content.

Parallax layers

Layers move at different speeds against the same scroll. A named scroll timeline makes that easy, since every layer shares one source of progress and only the translate distance in its keyframes differs.

For the design side of parallax scrolling, restraint wins. It is the pattern most likely to feel like too much, so one or two layers usually beat five. Layered setups you can copy are collected in these CSS parallax examples.

Sticky header and section scrubbing

A shrinking header starts with position: sticky and top: 0, plus a scroll() timeline that scales the logo and fades a shadow in. Section scrubbing works differently. A tall container holds a sticky child, and the container’s scroll progress changes that child (an image swap, a text highlight).

Shrink with transform and opacity. Animating height or padding drags layout back onto the main thread.

When is CSS enough, and when does a JavaScript library still win?

CSS handles progress bars, reveals, parallax, sticky headers and snapping without help. A library still earns its place when you need fine-grained scroll callbacks, pinned multi-step sequences or a scrolling feel the browser does not provide.

CSS animations triggered on scroll now cover the bulk of what libraries were installed for.

OptionRuns onBest forLimit
Native CSS scroll-driven animationsCompositor thread for transform and opacityProgress bars, reveals, parallaxNo progress callbacks, needs recent browser versions
GSAP ScrollTriggerJavaScript, main threadPinning, sequences, callbacksScript weight
LenisJavaScript scroll layerCustom scrolling feelNo native scroll-snap support
AOSJavaScript plus CSS transitionsClass-based revealsReveals only

GSAP ScrollTrigger

GSAP’s README states that every plugin, ScrollTrigger included, is free for commercial use, a change the README credits to Webflow. Cost is no longer the argument against it.

What it does well:

  • Pinning and scrubbing across several timelines
  • Callbacks when a trigger is entered or left

It also works in every browser. The downsides are that it runs in JavaScript, so its work shares the main thread with the rest of the page, and that it adds a dependency to a project that may not need one.

Lenis

Lenis replaces the browser’s scrolling feel with a scripted one, which no CSS property does.

Its README is upfront about the price. CSS scroll-snap isn’t supported (you have to use lenis/snap instead), and smooth scrolling stops working over iframes, since iframes do not forward wheel events.

In its favor are custom scroll inertia plus horizontal and nested scroll. Against it, the snap workaround and the iframe gaps.

AOS

Data attributes drive AOS, a small JavaScript library that triggers CSS animations as elements enter the viewport.

Setup takes minutes, and it works without view timelines. But it only reveals. It can’t scrub an animation along with scroll distance, and view() replaces it wherever support allows.

My own order of operations: start with CSS, bring in GSAP only when you need fine-grained scroll callbacks or pinned sequences, and reach for Lenis only when the product genuinely needs its own scrolling feel and you accept the snap workaround.

Which browsers support CSS scroll effects?

Chrome, Edge, Safari and Firefox now run scroll-driven animations without a flag. Firefox 159, released October 27, 2026, is the first stable Firefox to enable them by default.

Chrome for Developers puts Chrome and Edge at version 115. Safari shows up as version 26 in MDN’s compatibility data, introduced in the Safari 26 beta at WWDC25 (WebKit, June 2025).

MDN’s Firefox 159 release notes list scroll-driven animations as enabled by default, and MDN’s compatibility data now gives Firefox 159 as the first version. Earlier releases kept them behind the layout.css.scroll-driven-animations.enabled preference in about:config.

Scroll-driven animations were also named a focus area for Interop 2026, in an announcement dated February 12, 2026 (web.dev).

On release day, web-features, the dataset behind MDN’s Baseline badges, still listed scroll-driven animations as limited availability, so that badge can lag behind the browsers for a while.

Not every source agrees, though. One developer blog says Safari added support in 18.2 and Firefox shipped it in 133. MDN’s compatibility data and WebKit’s own WWDC25 material contradict both claims, with Safari at 26 and Firefox at 159.

Trust the primary data, then test anyway.

Firefox 158 and earlier, plus Safari before 26, are now the practical gap, so plan for cross-browser compatibility from the first line of CSS. Build each effect as an enhancement and test it in the browser you least expect to break.

How to ship scroll effects in browsers that lack support

Fallbacks stack. A feature test comes first, a polyfill after that, and an Intersection Observer reveal as the last option. Most pages never get past the first.

The feature test should check the exact value you use, such as @supports (animation-timeline: view()). A script can ask the same question with CSS.supports(‘animation-timeline’, ‘scroll()’) before it loads anything heavier.

The scroll-timeline polyfill on GitHub (flackr/scroll-timeline) implements ScrollTimeline and ViewTimeline, and it reads CSS animations that use scroll-timeline or view-timeline.

Its README does flag a limit. Stylesheets loaded from other origins may not be fetched and applied, because of browser security restrictions. And since it is JavaScript, the work lands back on the main thread.

Intersection Observer works differently. The API reports when a target crosses a threshold inside a root, and MDN notes the callback runs on the main thread. The default threshold is 0, so the callback fires as soon as a single pixel enters the viewport.

It reports entering and leaving, not progress. That makes it fine for a class toggle plus a CSS transition, and useless for scrubbing.

If the content only has to stay visible, the feature test alone is enough. Visitors on older browsers who need the same scrubbed effect call for the polyfill. And when an enter or leave toggle will do, Intersection Observer with a CSS transition is the lighter choice.

Start with the feature test. Add the others only when traffic from older browsers justifies the extra script.

Do CSS scroll effects stay smooth?

They stay smooth when the animation touches only compositor-friendly properties. transform and opacity skip layout and paint, so the compositor thread can run them even while the main thread is busy, and Chromium can composite filter animations too.

Stick to transform (translate, scale, rotate), opacity and, where it performs well, filter. Width and height, margin and padding, and top and left all stay bound to the main thread, so avoid animating them.

Lighthouse defines a non-composited animation as any animation that triggers the Style, Layout or Paint step of the rendering pipeline. Those animations can look janky on low-end phones or while heavy tasks occupy the main thread (Chrome for Developers).

The same documentation adds a second cost. Non-composited animations that move elements can raise Cumulative Layout Shift, while composited animations are excluded from it.

The Avoid non-composited animations audit lists every offending DOM node with the failure reasons Chrome reports. Run it after each new effect. A header that shrinks by animating height shows up immediately.

How to make scroll effects accessible

Wrap scroll-driven motion in @media (prefers-reduced-motion: no-preference), so only users who have not asked for less motion receive it. That media query reads the operating system setting, and MDN names scaling and panning large objects as common vestibular triggers.

Each effect carries a different risk, so handle them separately. Parallax should go entirely under reduce. Reveals and slides can swap translate and scale for a plain opacity fade. Progress bars can stay, since they convey position, and smooth scrolling should be reset to scroll-behavior: auto.

WCAG 2.2 still carries Success Criterion 2.3.3, Animation from Interactions. It sits at Level AAA, arrived in WCAG 2.1, and says motion animation triggered by interaction must be disableable unless the motion carries the function or the information.

W3C’s Understanding document singles out parallax scrolling as often non-essential. Reduced motion is one slice of web accessibility, but it is the slice scroll effects touch most.

Guidance isn’t uniform here either. MDN’s example tones a scaling animation down to a dissolve under reduce, while stricter guidance removes motion entirely. A workable split is fades for movement and removal for parallax.

Fallbacks need the same check. A polyfill or Intersection Observer reveal reads the setting with window.matchMedia(‘(prefers-reduced-motion: reduce)’).

Keyboard users need one more fix. A sticky header covers whatever receives focus unless the scroll container sets scroll-padding-top to the header height.

To test, emulate prefers-reduced-motion from the Rendering tools in Chrome DevTools, or switch Reduce Motion on in the operating system.

When do CSS scroll effects break or backfire?

Most failures are silent. The CSS parses, nothing throws, and the effect simply does not appear.

A sticky element that never pins usually has an ancestor with overflow set to hidden, auto or scroll. That ancestor becomes the scrolling context, so the element pins to it instead of the page. Remove the overflow, switch it to overflow: clip, or make that ancestor the scroller.

A progress bar that stays frozen is normally a scroll() problem. It resolves to the nearest ancestor scroll container, which can be a wrapper with overflow: auto rather than the page, and a container with nothing to scroll gives the timeline no progress. Use scroll(root) or name the timeline on the right element.

Mandatory snapping can feel like a trap. Full-page snapping turns browsing into a slide deck and frustrates anyone reading part of a section. Unless the layout really is a deck, proximity is the better default for usability.

Scroll-driven animations versus listener-based effects

The upsides are mostly about what you no longer have to maintain:

  • No listener to throttle or clean up
  • Compositor-thread execution for transform and opacity
  • Declarative rules that read like any other CSS animation
  • Static content remains if the browser ignores the timeline

On the downside:

  • Support is recent in Firefox (159) and Safari (26), so older versions need a fallback
  • It handles animation only, with no per-progress logic
  • Range syntax takes practice before it feels natural

CSS Scroll Effects FAQ

What is the difference between scroll-padding and scroll-margin?

scroll-padding lives on the scroll container and shrinks the area where snapped or linked content lands. The target element is where scroll-margin goes, and it pushes that element’s snap edge outward.

For a fixed header, set scroll-padding-top once on the container instead of margins on every target.

Can CSS fire a callback when an effect finishes?

Only partly. A CSS animation driven by a scroll or view timeline dispatches the usual animationstart and animationend events, and the Web Animations API exposes finish events for the same animations. CSS itself has no callback for reaching a particular point or percentage of progress inside a timeline.

Logic that must run on completion or visibility, such as loading data or tracking whether an element is visible, still usually needs JavaScript, typically an Intersection Observer.

Can JavaScript drive the same scroll timelines?

Yes. The Web Animations API exposes ScrollTimeline and ViewTimeline, and either one can be passed as the timeline option of element.animate().

That suits effects whose keyframes are computed at runtime, while static effects stay simpler in CSS.

Is horizontal scrolling possible with CSS only?

Yes. Set overflow-x: auto on the container, add scroll-snap-type: x with scroll-snap-align on the children, and you have a carousel without a script.

To animate along it, use scroll(nearest inline) as the timeline.

Why does my scroll animation not play in Firefox?

Firefox enables scroll-driven animations by default only from version 159 (October 27, 2026). Earlier versions keep them behind the layout.css.scroll-driven-animations.enabled preference, so the animation-timeline declaration is ignored there.

Check the version first, and protect visitors on older releases with an @supports block.

Where to Start With CSS Scroll Effects

Build a reading progress bar first, then a view-timeline reveal, and add parallax last. That order runs from the effect that carries information to the one that carries the most motion.

  1. Progress bar on scroll(root block)
  2. Reveal on view() with animation-range: entry
  3. Parallax layers, removed under reduced motion

To verify, run Chrome, Safari 26, Firefox 159 and one older Firefox, and emulate prefers-reduced-motion in each. The older Firefox run matters most, because it shows the static page that visitors who have not updated yet receive.

The trade-off is that visitors on older browsers get the static page, not the effect. That group shrinks as Firefox 159 rolls out, and MDN’s compatibility data checked on October 27, 2026 lists Firefox 159 next to Chrome 115 and Safari 26.

The progress bar is the quickest first build, and a guide to a CSS progress bar covers the element’s styling before any timeline is attached.