Entry 01

Carousel on native scroll

Use it for any horizontal row of cards. The carousel is a real scroll container, so swipe, trackpad, keyboard and momentum all work before any script loads.

Open
Swipe, drag the trackpad, and press the arrows. Each stop lands a card on the left padding, and the arrows turn off at both ends.
Code CSS · HTML · JS · 3 blocks · 41 lines
.carousel {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-padding-inline: 1.5rem;
  padding-inline: 1.5rem;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
}

.carousel > * {
  flex: none;
  scroll-snap-align: start;
}

@media (prefers-reduced-motion: no-preference) {
  .carousel { scroll-behavior: smooth; }
}

Arrow buttons are the one part that needs a script.

<button data-carousel-dir="-1" aria-label="Previous">←</button>
<button data-carousel-dir="1" aria-label="Next">→</button>
const carousel = document.querySelector(".carousel");
const [prev, next] = document.querySelectorAll("[data-carousel-dir]");
const sign = getComputedStyle(carousel).direction === "rtl" ? -1 : 1;

const step = (dir) => {
  const card = carousel.firstElementChild.getBoundingClientRect().width;
  carousel.scrollBy({ left: sign * dir * card });
};

const sync = () => {
  const max = carousel.scrollWidth - carousel.clientWidth;
  const scrolled = Math.abs(carousel.scrollLeft);
  prev.disabled = scrolled <= 1;
  next.disabled = scrolled >= max - 1;
};

prev.addEventListener("click", () => step(-1));
next.addEventListener("click", () => step(1));
carousel.addEventListener("scroll", sync, { passive: true });
sync();
Why it works, the rules and the credits 5 reasons · 3 rules · credits

Why it works

  1. Scroll snap makes the browser do the scrolling and the landing. Every input method gets the same result.
  2. scrollBy moves one card width and snapping picks the final position. The script never calculates an offset.
  3. In a right-to-left row scrollLeft starts at 0 and goes negative. sign and Math.abs() make the script work in both directions.
  4. scroll-behavior: smooth in CSS animates the script's scroll. The media query turns the animation off for people who ask for reduced motion.
  5. overscroll-behavior-x: contain stops a swipe at the end of the row from triggering the browser's back gesture.

Rules

  1. Never move slides with transform, and never handle wheel or touch events yourself.
  2. Match scroll-padding-inline to padding-inline so a snapped card lines up with the page content.
  3. Chrome 135 can draw the arrows and dots in CSS with ::scroll-button() and ::scroll-marker. Firefox and Safari have neither, so use the script.

Credits

Borrowed from: Steve Sewell, Build buttery smooth carousels with pure CSS like Nike. This version swaps his scrollIntoView() for scrollBy(), which can only scroll the carousel and never the page.

Docs: Adam Argyle, Carousels with CSS · MDN: scroll snap · MDN: scrollBy()

Entry 02

Scroll area between a fixed header and footer

Use it for the body of a modal, a chat list, a drawer or a sidebar. The panel grows with its content up to a limit, then the middle scrolls and the header and footer stay put.

Open
Each panel is at most 80% of the frame's height. Its header and footer stay put and the list between them scrolls. The second panel has a form between the panel and the list, and behaves the same. Scroll a list to its end: the page behind does not move. Press Fewer rows: both panels shrink to their content and the scrollbar goes.
Code CSS · 19 lines
.panel {
  display: flex;
  flex-direction: column;
  max-block-size: 80dvh;
}

.panel-body {
  flex: 1;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-gutter: stable;
}

.panel-wrap {
  flex: 1;
  min-block-size: 0;
  display: flex;
  flex-direction: column;
}
Why it works, the rules and the credits 5 reasons · 5 rules · support · credits

Why it works

  1. A flex item will not shrink below its content. A scroll container is the exception, with a minimum of zero, so the body shrinks to the space left and scrolls.
  2. An element between the panel and the body, such as a form, is not a scroll container. It keeps the height of its content and pushes the footer out of the panel. min-block-size: 0 gives it the same zero minimum.
  3. auto shows a scrollbar only when there is overflow.
  4. scrollbar-gutter does not inherit. The reset's declaration on :root covers the page and no inner scroller.
  5. overscroll-behavior: contain stops the scroll from passing to the page at the end of the list.

Rules

  1. The panel needs a limit, either max-block-size or a height. With no limit nothing overflows.
  2. .panel-wrap is only for an element that sits between the panel and the body. Leave it out when the body is a direct child.
  3. A header or footer with a set height shrinks to its content when the body overflows. Give it flex: none, or size it with padding.
  4. Keep min-height: 0 out of the reset. On every element it lets each item of a fixed-height column shrink under its own text.
  5. The gutter takes the scrollbar's width on a short list as well. That is the price of no shift.

Support

scrollbar-gutter in Chrome 94, Firefox 97, Safari 18.2. overscroll-behavior in Chrome 63, Firefox 59, Safari 16. dvh in Chrome 108, Firefox 101, Safari 15.4.

Credits

Borrowed from: Ahmad Shadeed, Defensive CSS, the tips Scrollbars on demand, Scrollbar gutter, Scroll chaining and Minimum content size in flexbox. He gives the four declarations one by one, and the panel around them is added here.

Docs: MDN: scrollbar-gutter · MDN: overscroll-behavior · MDN: min-height

Entry 03

Styles that apply only when a scroller overflows

Use it on any row that may or may not fit, such as tabs, a toolbar or a table wrapper. It fades the edges only when there is something to scroll to. It replaces a ResizeObserver that compares scrollWidth with clientWidth.

Open
At full width both rows fit and nothing is faded. Make the frame narrower until they overflow. The first row fades at both edges. The second fades only the side that still has content: sharp at the start, then fading there too once you scroll.
Code CSS · 2 blocks · 47 lines
@keyframes overflowing {
  from, to { --fade: 2rem; }
}

.scroller {
  --fade: 0px;
  overflow-x: auto;
  animation: overflowing linear;
  animation-timeline: scroll(self inline);
  mask-image: linear-gradient(
    to right,
    transparent,
    #000 var(--fade),
    #000 calc(100% - var(--fade)),
    transparent
  );
}

To fade only the side that still has content, register the two lengths and give the keyframes different ends:

@property --fade-start {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

@property --fade-end {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

@keyframes edges {
  from { --fade-start: 0px; --fade-end: 2rem; }
  10%, 90% { --fade-start: 2rem; --fade-end: 2rem; }
  to { --fade-start: 2rem; --fade-end: 0px; }
}

.scroller-edges {
  overflow-x: auto;
  animation: edges linear;
  animation-timeline: scroll(self inline);
  mask-image: linear-gradient(
    to right,
    transparent,
    #000 var(--fade-start),
    #000 calc(100% - var(--fade-end)),
    transparent
  );
}
Why it works, the rules and the credits 4 reasons · 4 rules · support · credits

Why it works

  1. A scroll timeline is inactive while its scroller has nothing to scroll. An animation on an inactive timeline has no effect, so the keyframes apply only when the content overflows.
  2. In the first block from and to hold the same value. Scroll position changes nothing and the animation is an on/off switch.
  3. The keyframes set a custom property, and custom properties inherit. The scroller and everything inside it can read the result.
  4. A mask fades the content itself, so it works over any background. An overlay gradient has to match the color behind it.

Rules

  1. Write animation-timeline after animation. The shorthand resets the timeline.
  2. Declare the default outside the keyframes, as --fade: 0px does here.
  3. Any value can go in the keyframes, including keywords. Stripe flips its navigation to row-reverse this way when a translation is too long to fit.
  4. Write no fallback. Firefox has no scroll timelines, so the row scrolls there without the fade.

Support

animation-timeline in Chrome 115 and Safari 26. Firefox has it in Nightly only.

Credits

Borrowed from: Stripe, in the navigation on stripe.com · Bramus Van Damme, Detect if an element can scroll or not with only CSS

Docs: MDN: scroll()

Entry 04

Anchor targets that clear a sticky header

Use it on any page with in-page links and a fixed or sticky header.

Open
Click each link. The section heading lands just below the header, never under it.
Code CSS · 5 lines
html { scroll-padding-block-start: 5rem; }

@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}
Why it works, the rules and the credits 1 reason · 2 rules · support · credits

Why it works

  1. The browser offsets every scroll to an anchor by the padding, so the target lands below the header. No script calculates offsets.

Rules

  1. Keep the offset outside the motion query, so people who ask for reduced motion still get it.
  2. Tie the value to the header's height. Share one custom property between the two.

Support

Chrome
69
Firefox
68
Safari
14.1

Credits

Entry 05

No rubber-band bounce on desktop

Use it on app-like pages with fixed UI, such as a sidebar or a sticky header. On macOS a trackpad scroll stretches the whole page past its edge and drags the fixed UI along.

Open
Open it in its own tab and scroll past the top with a trackpad. The page does not stretch. On a touch device it still does.
Code CSS · 5 lines
html { overscroll-behavior-y: none; }

@media (any-pointer: coarse) {
  html { overscroll-behavior-y: auto; }
}
Why it works, the rules and the credits 3 reasons · 3 rules · credits

Why it works

  1. overscroll-behavior-y: none on the root turns off the bounce.
  2. Touch devices get the default back, because pull to refresh depends on it.
  3. any-pointer checks every input, where pointer checks only the primary one. A device with a touchscreen plus a mouse or stylus keeps pull to refresh. Jhey's example is a foldable.

Rules

  1. Set it on html. The viewport takes its overscroll behavior from the root element.
  2. Use the -y longhand. overscroll-behavior-x: none also turns off swipe to go back.
  3. Give inner scroll containers, such as a sheet, a chat list or a sidebar, overscroll-behavior: contain. They keep their own bounce and stop the page behind them from moving. Never use a touchmove listener with preventDefault() for this.

Credits

Borrowed from: wits, @witsdev on X, 2026-09-30, describing how Vercel gates it behind pointer: fine · Jhey Tompkins, @jh3yy on X, for the any-pointer version · Emil Kowalski, mobile-native skill, for the inner containers

Docs: MDN: overscroll-behavior · MDN: any-pointer

Entry 06

Content clear of the notch

Use it on fixed headers, bottom bars, toasts and sheets in anything that should feel like an app on a phone. Add viewport-fit=cover to the viewport meta tag, then pad those elements by the safe-area insets.

Open
On a desktop every inset is 0px: the readouts say 0px, 0px and 16px, and only the sheet shows a sulphur band. Open it in its own tab on a phone with a notch or a home indicator. Wherever an inset is not 0px, a sulphur band of that size appears and the text stays clear of it.
Code CSS · 5 lines
.app-header { padding-block-start: env(safe-area-inset-top, 0px); }

.bottom-bar { padding-block-end: env(safe-area-inset-bottom, 0px); }

.sheet { padding-block-end: calc(1rem + env(safe-area-inset-bottom, 0px)); }
Why it works, the rules and the credits 2 reasons · 3 rules · support · credits

Why it works

  1. By default the browser keeps the page inside the safe area and leaves the notch and home-indicator zones as bare background. viewport-fit=cover lets the page paint edge to edge.
  2. env(safe-area-inset-*) gives the size of each zone, so the content moves clear while the background still fills the screen.

Rules

  1. Without viewport-fit=cover every inset is 0px and the padding does nothing.
  2. Give env() a fallback of 0px wherever it sits inside calc().
  3. Normal page content needs none of this. The header's padding covers it.

Support

Chrome
69
Firefox
65
Safari
11.1

Credits

Borrowed from: Emil Kowalski, mobile-native skill