Go Back

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

Scroll the content and toggle shadows to see the effect.

The Goal

We want to replicate Part 1's behavior exactly:

  1. Track scroll position as a value from 0 (top) to 1 (bottom)
  2. Use that value to control shadow opacity
  3. 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);

Dividing gives us a normalized 0 to 1 value, just like Framer Motion.

--scroll-progress0.00

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

Scroll to see the progress value update in real-time.

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.

Note

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.

Note

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:

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 */}
<div
className="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 */}
<div
className="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:

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 shadows
element.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:

1
2
3
4
5
6
7
8
Horizontal scroll shadows using the same technique.
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

Part 1: 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

Part 2: CSS Variables

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

Both approaches animate opacity — the visual result is identical.
Part 1 (Framer Motion)Part 2 (CSS Variables)
~30kb dependency~0kb (just a hook)
MotionValue for opacityCSS variable for opacity
useTransform for derived valuesCSS calc() for derived values
Works outside React renderWorks 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:

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 (
<div
ref={ref}
className={cn(
"relative",
isVertical ? "overflow-y-auto" : "overflow-x-auto",
className
)}
>
<div
className={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}
<div
className={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:

  1. Compute scroll progress (0→1) on each scroll event
  2. Expose it as a CSS custom property
  3. 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