Scroll Shadows (Pt. 2)
In Part 1, we built scroll shadows using Framer Motion. The library gave us useScroll to track scroll position and MotionValue to animate opacity outside React's render cycle.
But Framer Motion adds ~30kb to your bundle. What if we could achieve the same effect with just a small hook and CSS?
New message from Alex
2m ago
Sarah liked your post
5m ago
New comment on your photo
12m ago
Jordan started following you
1h ago
New message from Taylor
2h ago
3 people liked your comment
3h ago
Reply to your thread
5h ago
Casey started following you
6h ago
The Goal
We want to replicate Part 1's behavior exactly:
- Track scroll position as a value from
0(top) to1(bottom) - Use that value to control shadow opacity
- Keep animations smooth (no React re-renders on scroll)
The key insight: CSS custom properties update without triggering React renders. If we set --scroll-progress on every scroll event, CSS can use it directly for opacity — no animation library needed.
Tracking Scroll Progress
In Part 1, Framer Motion's useScroll gave us scrollYProgress — a value from 0 to 1. Let's compute that ourselves:
const progress =element.scrollTop / (element.scrollHeight - element.clientHeight);
scrollTop— how far we've scrolled from the topscrollHeight - clientHeight— the maximum scrollable distance
Dividing gives us a normalized 0 to 1 value, just like Framer Motion.
New message from Alex
2m ago
Sarah liked your post
5m ago
New comment on your photo
12m ago
Jordan started following you
1h ago
New message from Taylor
2h ago
3 people liked your comment
3h ago
Reply to your thread
5h ago
Casey started following you
6h ago
Building the Hook
Here's a hook that tracks scroll progress and exposes it as a CSS custom property:
import { useEffect, useRef } from "react";export function useScrollProgress<T extends HTMLElement = HTMLDivElement>() {const ref = useRef<T>(null);useEffect(() => {const element = ref.current;if (!element) return;function updateProgress() {if (!element) return;const maxScroll = element.scrollHeight - element.clientHeight;const progress = maxScroll > 0 ? element.scrollTop / maxScroll : 0;element.style.setProperty("--scroll-progress", String(progress));}updateProgress();element.addEventListener("scroll", updateProgress, { passive: true });return () => element.removeEventListener("scroll", updateProgress);}, []);return ref;}
The hook creates and returns a ref — just attach it to your scroll container. The { passive: true } option tells the browser we won't call preventDefault(), allowing it to optimize scroll performance.
We're setting the CSS variable on the scroll container itself. This means
child elements can access it via var(--scroll-progress) without any
inheritance issues.
A Note on Performance
CSS custom properties inherit to all children by default. In complex scroll areas with deep DOM trees, this can trigger style recalculations across the entire subtree — what Matt Perry calls "the inheritance bomb".
You can prevent this with CSS.registerProperty({ inherits: false }), which is what Base UI does. However, this means child elements can't access the variable unless you explicitly pass it down.
For our simple use case (shadows are direct children), inheritance is actually what we want — the shadow elements need to read --scroll-progress from their parent. The performance impact is negligible since we're only affecting a few elements.
We're animating opacity, which is one of the compositor-friendly properties
(along with transform, filter, and clip-path). These can be
hardware-accelerated and skip the browser's layout and paint steps entirely.
Controlling Shadow Opacity
Now we need to convert --scroll-progress into opacity values for each shadow:
- Start shadow (top): opacity should equal progress —
0at top,1when scrolled - End shadow (bottom): opacity should be inverse —
1at top,0at bottom
CSS calc() makes this easy:
/* Start shadow: opacity = progress */opacity: var(--scroll-progress, 0);/* End shadow: opacity = 1 - progress */opacity: calc(1 - var(--scroll-progress, 1));
The fallback values (0 and 1) handle the initial render before JavaScript runs.
The Component
Putting it together:
"use client";import { cn } from "./utils";import { useScrollProgress } from "./use-scroll-progress";export function ScrollShadows({className,children,}: React.PropsWithChildren<{ className?: string }>) {const ref = useScrollProgress();return (<div ref={ref} className={cn("relative overflow-y-auto", className)}>{/* Start shadow */}<divclassName="pointer-events-none sticky top-0 -mb-10 h-10 bg-linear-to-b from-black/15 to-transparent"style={{ opacity: "var(--scroll-progress, 0)" }}/>{children}{/* End shadow */}<divclassName="pointer-events-none sticky bottom-0 -mt-10 h-10 bg-linear-to-t from-black/15 to-transparent"style={{ opacity: "calc(1 - var(--scroll-progress, 1))" }}/></div>);}
We're using the same sticky positioning trick from Part 1 — shadows stay fixed at the edges while content scrolls beneath them. The negative margins (-mb-10, -mt-10) prevent the shadows from taking up space in the document flow.
Handling Edge Cases
What happens when content doesn't overflow? In Part 1, we had to detect this and hide shadows. Here, it's automatic:
- If
scrollHeight === clientHeight, thenmaxScrollis0 - We return
0for progress, so start shadow hasopacity: 0 - End shadow has
opacity: calc(1 - 0) = 1... wait, that's wrong!
We need to handle non-overflowing containers:
function updateProgress() {if (!element) return;const maxScroll = element.scrollHeight - element.clientHeight;if (maxScroll <= 0) {// No overflow — hide both shadowselement.style.setProperty("--scroll-progress", "0");element.style.setProperty("--has-overflow", "0");} else {const progress = element.scrollTop / maxScroll;element.style.setProperty("--scroll-progress", String(progress));element.style.setProperty("--has-overflow", "1");}}
Then in CSS, multiply by --has-overflow:
/* End shadow: only visible when there's overflow */opacity: calc((1 - var(--scroll-progress, 1)) * var(--has-overflow, 0));
Horizontal Scrolling
The same pattern works for horizontal scrolling — just swap the axes:
export function useScrollProgress<T extends HTMLElement = HTMLDivElement>(axis: "x" | "y" = "y") {const ref = useRef<T>(null);useEffect(() => {const element = ref.current;if (!element) return;function updateProgress() {if (!element) return;const [scrollPos, scrollSize, clientSize] =axis === "y"? [element.scrollTop, element.scrollHeight, element.clientHeight]: [element.scrollLeft, element.scrollWidth, element.clientWidth];const maxScroll = scrollSize - clientSize;const hasOverflow = maxScroll > 0;const progress = hasOverflow ? scrollPos / maxScroll : 0;element.style.setProperty("--scroll-progress", String(progress));element.style.setProperty("--has-overflow", hasOverflow ? "1" : "0");}updateProgress();element.addEventListener("scroll", updateProgress, { passive: true });return () => element.removeEventListener("scroll", updateProgress);}, [axis]);return ref;}
Comparing the Approaches
New message from Alex
2m ago
Sarah liked your post
5m ago
New comment on your photo
12m ago
Jordan started following you
1h ago
New message from Taylor
2h ago
3 people liked your comment
3h ago
New message from Alex
2m ago
Sarah liked your post
5m ago
New comment on your photo
12m ago
Jordan started following you
1h ago
New message from Taylor
2h ago
3 people liked your comment
3h ago
| Part 1 (Framer Motion) | Part 2 (CSS Variables) |
|---|---|
| ~30kb dependency | ~0kb (just a hook) |
| MotionValue for opacity | CSS variable for opacity |
| useTransform for derived values | CSS calc() for derived values |
| Works outside React render | Works outside React render |
Both approaches animate the same property (opacity) using hardware-accelerated compositing. The visual result is identical — we've just swapped the mechanism for tracking and applying the value.
Handling Resize
One edge case: if the container resizes, our overflow detection might become stale. Let's add a ResizeObserver:
useEffect(() => {const element = ref.current;if (!element) return;function updateProgress() {/* ... */}updateProgress();element.addEventListener("scroll", updateProgress, { passive: true });const resizeObserver = new ResizeObserver(updateProgress);resizeObserver.observe(element);return () => {element.removeEventListener("scroll", updateProgress);resizeObserver.disconnect();};}, [ref, axis]);
Going Further
If you need more scroll area features — custom scrollbars, bidirectional scrolling, or better accessibility — check out Base UI's ScrollArea. It uses the same CSS variable approach internally and adds:
- Cross-browser custom scrollbars
data-*attributes for overflow state- Corner handling for bidirectional scroll
- Performance optimizations with
CSS.registerProperty()
Final Code
"use client";import { cn } from "./utils";import { useScrollProgress } from "./use-scroll-progress";type Axis = "x" | "y";export function ScrollShadows({axis = "y",className,children,}: React.PropsWithChildren<{axis?: Axis;className?: string;}>) {const ref = useScrollProgress(axis);const isVertical = axis === "y";return (<divref={ref}className={cn("relative",isVertical ? "overflow-y-auto" : "overflow-x-auto",className)}><divclassName={cn("pointer-events-none sticky from-black/15 to-transparent",isVertical? "top-0 -mb-10 h-10 bg-linear-to-b": "left-0 -mr-10 h-full w-10 bg-linear-to-r")}style={{ opacity: "var(--scroll-progress, 0)" }}/>{children}<divclassName={cn("pointer-events-none sticky from-black/15 to-transparent",isVertical? "bottom-0 -mt-10 h-10 bg-linear-to-t": "right-0 -ml-10 h-full w-10 bg-linear-to-l")}style={{opacity:"calc((1 - var(--scroll-progress, 1)) * var(--has-overflow, 0))",}}/></div>);}
Conclusion
We've replicated Part 1's scroll shadows without Framer Motion:
- Compute scroll progress (0→1) on each scroll event
- Expose it as a CSS custom property
- Use CSS
calc()to derive shadow opacities
The result is visually identical — both animate opacity using hardware acceleration. The difference is ~30kb less JavaScript and one fewer dependency.
Resources
- CSS Custom Properties — MDN reference
- Base UI ScrollArea — Full-featured scroll area component
- Part 1: Scroll Shadows — The Framer Motion approach