Go Back

Scroll Shadows

Scroll shadows are a visual cue that indicates more content exists beyond the visible area. They fade in as you scroll away from an edge and fade out as you approach it.

Scroll the article — notice how shadows appear and disappear at the edges.

By the end of this post, we'll build a reusable <ScrollShadows /> component that adds this effect to any scrollable container. The goal is to make it as simple as:

<ScrollShadows>
<ul>
<li>...</li>
<li>...</li>
</ul>
</ScrollShadows>

What We're Building

Before diving in, let's define our acceptance criteria:

The Approach

We'll use Framer Motion for two reasons:

  1. useScroll — gives us scroll position as a reactive value
  2. Performance — tracks values outside React's render cycle, so scroll events don't trigger re-renders (keeping animations smooth at 60fps)
npm install framer-motion
Note

Using an animation library adds bundle size. If that's a concern, check out this pure CSS solution first — it's limited but lightweight.

The core idea: we need two values to control shadow opacity:

Tracking Scroll Progress

Framer Motion's useScroll hook tracks scroll position as a decimal from 0 (top) to 1 (bottom):

const articleRef = useRef(null);
const { scrollYProgress } = useScroll({ container: articleRef });
<article ref={articleRef} className="overflow-y-auto">
{children}
</article>;
Scroll the article to see the value update in real-time.

This gives us our starting point. The scrollYProgress value already matches what we need for startingShadowVisibility — it's 0 at the top and increases as we scroll.

For endingShadowVisibility, we need the inverse: 1 at the top, 0 at the bottom. Framer Motion's useTransform hook lets us derive one MotionValue from another:

const endingShadowVisibility = useTransform(
scrollYProgress,
(latest) => 1 - latest
);
Note

Why useTransform instead of just 1 - scrollYProgress? Because scrollYProgress is a MotionValue (a reactive container), not a plain number. useTransform creates a new MotionValue that automatically updates when the source changes.

Here's our initial hook:

import { useScroll, useTransform } from "framer-motion";
type OptionsType = {
ref: React.RefObject<HTMLElement | null>;
};
function _useScrollShadows({ ref }: OptionsType) {
const { scrollYProgress } = useScroll({ container: ref });
const startingShadowVisibility = scrollYProgress;
const endingShadowVisibility = useTransform(
scrollYProgress,
(latest) => 1 - latest
);
return [startingShadowVisibility, endingShadowVisibility] as const;
}

Let's visualize both values. Toggle the shadows on to see them respond to scroll:

opacity: 0.0
opacity: 1.0

Looking good! But there's a bug hiding. Let's see what happens when the content doesn't overflow:

Handling Edge Cases

opacity: 0.0
opacity: 1.0
Toggle shadows ON — why is the top shadow visible?

When a container doesn't overflow, useScroll defaults scrollYProgress to 1 (not 0). This means our starting shadow shows at full opacity even though there's nothing to scroll.

We need to detect non-overflowing containers and force the shadow to hide:

const startingShadowVisibility = useTransform(scrollYProgress, (latest) => {
const element = ref.current;
if (element === null) return latest;
const isOverflowing = element.scrollHeight > element.clientHeight;
if (isOverflowing) {
return latest; // normal behavior
} else {
return 0; // hide shadow when nothing to scroll
}
});

Now both shadows correctly hide when there's no overflow:

opacity: 0.0
opacity: 0.0
Without any overflow on our scrolling container, both shadows are correctly hidden.

Here's the complete hook:

import { useScroll, useTransform } from "framer-motion";
type OptionsType = {
ref: React.RefObject<HTMLElement | null>;
};
function _useScrollShadows({ ref }: OptionsType) {
const { scrollXProgress } = useScroll({ container: ref });
const startingShadowVisibility = useTransform(scrollXProgress, (latest) => {
const element = ref.current;
if (element === null) {
return latest;
}
const isOverflowing = element.scrollWidth > element.clientWidth;
if (isOverflowing) {
return latest; // preserve existing behavior
}
return 0; // override the default value
});
const endingShadowVisibility = useTransform(
scrollXProgress,
(latest) => 1 - latest
);
return [startingShadowVisibility, endingShadowVisibility] as const;
}

Positioning the Shadows

Now for the visual part. We'll use a clever CSS technique: position: sticky with negative margins.

Here's why it works:

<div ref={ref} className="relative flex flex-col overflow-y-auto [--size:48px]">
{/* Top shadow: stuck to top, negative bottom margin */}
<div className="sticky top-0 -mb-(--size) h-(--size) bg-linear-to-b from-black/20 to-transparent" />
{children}
{/* Bottom shadow: stuck to bottom, negative top margin */}
<div className="sticky bottom-0 -mt-(--size) h-(--size) bg-linear-to-t from-black/20 to-transparent" />
</div>
{ top: 0, height: 32px, marginBottom: -32px }
{ bottom: 0, height: 32px, marginTop: -32px }
Drag the slider to see how shadow size affects the layout.
Tip

I'm using Tailwind with a cn helper from shadcn/ui for conditional classes. See the Vanilla CSS section at the end for a plain CSS version.

Polishing the Effect

The blue overlays are great for debugging, but real scroll shadows should be subtle gradients that blend with your background:

// Instead of solid colors:
className = "bg-blue-400/30";
// Use gradients that fade to transparent:
className = "bg-linear-to-b from-white to-transparent"; // top shadow
className = "bg-linear-to-t from-white to-transparent"; // bottom shadow

For dark backgrounds, use from-black/20 or match your background color. The key is subtlety — scroll shadows should guide attention, not demand it.

Supporting Horizontal Scrolling

Let's extend our implementation to support carousels and horizontal lists.

First, we add an axis option to the hook:

type AxisType = "x" | "y";
function useScrollShadows({
ref,
axis,
}: {
ref: RefObject<HTMLElement>;
axis: AxisType;
}) {
const { scrollXProgress, scrollYProgress } = useScroll({ container: ref });
const scrollProgress = axis === "x" ? scrollXProgress : scrollYProgress;
const isOverflowing = (element: HTMLElement) => {
return axis === "x"
? element.scrollWidth > element.clientWidth
: element.scrollHeight > element.clientHeight;
};
// ... rest of hook using scrollProgress and isOverflowing
}

Then update the component to position shadows on left/right for horizontal scrolling:

<div
ref={ref}
data-axis={axis}
className={cn(
"group relative flex",
"data-[axis=x]:flex-row data-[axis=x]:overflow-x-auto",
"data-[axis=y]:flex-col data-[axis=y]:overflow-y-auto"
)}
>
<motion.div
style={{ opacity: startingShadowVisibility }}
className={cn(
"pointer-events-none sticky shrink-0",
// Horizontal: left edge, full height
"group-data-[axis=x]:left-0 group-data-[axis=x]:top-0 group-data-[axis=x]:bottom-0 group-data-[axis=x]:w-(--size) group-data-[axis=x]:-mr-(--size)",
// Vertical: top edge, full width
"group-data-[axis=y]:top-0 group-data-[axis=y]:h-(--size) group-data-[axis=y]:-mb-(--size)"
)}
/>
{children}
{/* ... end shadow with mirrored positioning */}
</div>

Here's horizontal scrolling in action:

Final Code

Here's the complete implementation:

Tailwind

import { useRef } from "react";
import { motion } from "framer-motion";
import { cn } from "./utils";
import { useScrollShadows } from "./use-scroll-shadows";
export function ScrollShadows({
axis = "y",
className,
children,
}: React.PropsWithChildren<{
axis?: "x" | "y";
className?: string;
}>) {
const ref = useRef<HTMLDivElement>(null);
const [start, end] = useScrollShadows({ ref, axis });
return (
<div
ref={ref}
data-axis={axis}
className={cn(
"group relative flex [--size:40px]",
"data-[axis=x]:flex-row data-[axis=x]:overflow-x-auto",
"data-[axis=y]:flex-col data-[axis=y]:overflow-y-auto",
className
)}
>
<motion.div
style={{ opacity: start }}
className={cn(
"pointer-events-none sticky shrink-0 from-black/10 to-transparent",
"group-data-[axis=x]:left-0 group-data-[axis=x]:top-0 group-data-[axis=x]:bottom-0 group-data-[axis=x]:w-(--size) group-data-[axis=x]:-mr-(--size) group-data-[axis=x]:bg-linear-to-r",
"group-data-[axis=y]:top-0 group-data-[axis=y]:h-(--size) group-data-[axis=y]:-mb-(--size) group-data-[axis=y]:bg-linear-to-b"
)}
/>
{children}
<motion.div
style={{ opacity: end }}
className={cn(
"pointer-events-none sticky shrink-0 from-black/10 to-transparent",
"group-data-[axis=x]:right-0 group-data-[axis=x]:top-0 group-data-[axis=x]:bottom-0 group-data-[axis=x]:w-(--size) group-data-[axis=x]:-ml-(--size) group-data-[axis=x]:bg-linear-to-l",
"group-data-[axis=y]:bottom-0 group-data-[axis=y]:h-(--size) group-data-[axis=y]:-mt-(--size) group-data-[axis=y]:bg-linear-to-t"
)}
/>
</div>
);
}

Vanilla CSS

import { useRef } from "react";
import { motion } from "framer-motion";
import { useScrollShadows } from "./use-scroll-shadows";
import "./styles.css";
export function ScrollShadows({
axis = "y",
className,
children,
}: React.PropsWithChildren<{
axis?: "x" | "y";
className?: string;
}>) {
const ref = useRef<HTMLDivElement>(null);
const [start, end] = useScrollShadows({ ref, axis });
return (
<div ref={ref} data-axis={axis} className={`scroll-shadows ${className}`}>
<motion.div
style={{ opacity: start }}
className="scroll-shadow scroll-shadow-start"
/>
{children}
<motion.div
style={{ opacity: end }}
className="scroll-shadow scroll-shadow-end"
/>
</div>
);
}

Conclusion

We built a reusable scroll shadow component that:

Scroll shadows are a small detail, but they're the kind of polish that makes interfaces feel thoughtful. They guide users without demanding attention — exactly what good UI should do.

Note

Accessibility note: Scroll shadows are a visual enhancement, not a replacement for proper scrollable region semantics. Screen reader users won't perceive them, so ensure your scrollable areas are properly labeled with role and aria-label attributes when appropriate.

Resources