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.
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:
- Support both top and bottom shadows (start and end)
- Shadows fade independently based on scroll position
- Gracefully handle containers that don't overflow (no shadows shown)
- Fully customizable via CSS
- Bonus: support horizontal scrolling too
The Approach
We'll use Framer Motion for two reasons:
useScroll— gives us scroll position as a reactive value- 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
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:
startingShadowVisibility—0when at the top,1when scrolled awayendingShadowVisibility—1when at the top,0when at the bottom
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>;
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);
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.0opacity: 1.0Looking good! But there's a bug hiding. Let's see what happens when the content doesn't overflow:
Handling Edge Cases
opacity: 0.0opacity: 1.0When 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.0opacity: 0.0Here'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:
stickyelements stay fixed relative to their scroll container- A negative margin equal to the element's height removes it from document flow
- The shadow overlays the content without pushing it down
<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 }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 shadowclassName = "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:
<divref={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.divstyle={{ 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 (<divref={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.divstyle={{ 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.divstyle={{ 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.divstyle={{ opacity: start }}className="scroll-shadow scroll-shadow-start"/>{children}<motion.divstyle={{ opacity: end }}className="scroll-shadow scroll-shadow-end"/></div>);}
Conclusion
We built a reusable scroll shadow component that:
- Tracks scroll position with Framer Motion's
useScroll - Derives shadow visibility with
useTransform - Handles edge cases (non-overflowing containers)
- Uses sticky positioning with negative margins for layout
- Supports both vertical and horizontal scrolling
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.
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
- Adding Elegant Shadows with React — the article that inspired the sticky positioning technique
- CSS Scroll Shadows Generator — pure CSS alternative
- Framer Motion Documentation — for
useScrollanduseTransform