Scroll Shadows
Scroll shadows are a visual effect often used to indicate that there is more content to be scrolled in a particular direction, usually beyond the current viewport.
Here is an interactive example of scroll shadows added to an especially long article:
By the end of this post, we'll create a reusable component called <ScrollShadows />
that can be used to easily add shadows to anything with overflowing content, such as articles, carousels, or lists. It'll be powered internally by useScrollShadows()
— which is where we'll start our journey.
Ultimately our goal is to make the component as easy to use as:
<ScrollShadows> <ul> <li>...</li> <li>...</li> <li>...</li> </ul></ScrollShadows>
Before we start, let's determine the scope of our implementation with some acceptance criteria:
- Must support both starting and ending shadows
- Independently fade in & out in response to scrolling progression
- Must gracefully no-op if the container is not overflowing
- Entirely customizable via CSS
...and for extra credit:
- Support both horizontal and vertical scrolling containers
With that said, let's begin!
Getting Started
First we'll need to install our only dependency:
npm install framer-motion
For those unfamiliar: Framer Motion is an incredible library that enables developers to create performant animations for the web. It provides some useful hooks useScroll
and useTransform
which we'll ultimately leverage to achieve the desired effect.
While it's certainly possible to create our own user-land useScroll
implementation and remove the dependency altogether, Framer Motion will help us get started quickly with the added benefit of tracking and animating all of our values outside the React render cycle for optimized performance.
That said, using any animation library will add additional size to the JavaScript bundle that will be served to your users. Make sure you're okay with that trade-off!
If bundle size is a concern, I'd recommend first exploring a fun (albeit limiting) pure CSS solution to see if that fits your needs.
Tracking scroll progress
Our first objective is to listen to scroll events in the overflowing DOM element.
To easily obtain the element's scroll position, we provide a ref
to the useScroll
hook provided by Framer Motion like this:
const articleRef = useRef(null)const { scrollYProgress } = useScroll({ container: articleRef })<article ref={articleRef} className="overflow-y-auto"> {children}</article>
scrollYProgress
will track the users vertical scroll progress with a decimal number between 0
and 1
, with 0
being the initial starting position, and 1
indicating the user has reached the end of the scrolling container.
For a helpful visualization of scrollYProgress
in action, here we can see our scroll progress updating in real-time on a simulated article:
scrollYProgress: 0.0
We're only tackling vertical scrolling for now, but rest assured — we'll address horizontal scrolling later in the article for some extra credit.
Armed with this knowledge, we can start creating the foundation for our custom hook:
import { useScroll } from "framer-motion";type OptionsType = { ref: React.RefObject<HTMLElement>;};function useScrollShadows({ ref }: OptionsType) { const { scrollYProgress } = useScroll({ container: ref }); // do something with scrollYProgress}
The hook consumer will be responsible for passing in the appropriate reference to the overflowing DOM node. Now we can start thinking about what this hook should return.
We need to track two different values:
- The visibility of the starting shadow
- The visibility of the ending shadow
We'll call the two values startingShadowVisibility
and endingShadowVisibility
. We'll match the behavior of scrollYProgress
and have each value hold a decimal number ranging from 0
to 1
as well, with 0
meaning the shadow should be fully hidden (opacity: 0
) and 1
being fully visible (opacity: 1
).
For the starting shadow, we know it should begin initially hidden to the user and gradually increase in visibility as the overflowing element is scrolled. This actually mirrors the value returned from scrollYProgress
— which starts at 0
and ends at 1
once the container can't scroll any further — so (for now) we can just alias startingShadowVisibility
to scrollYProgress
. Easy!
const startingShadowVisibility = scrollYProgress;
As for the ending shadow, we know it should begin fully visible to the user and gradually decrease in visibility as the overflowing element is scrolled. This is actually the inverse behavior of startingShadowVisibility
, as we want the ending shadow to have full visibility when scrollYProgress
is 0
and gradually decrease as the scroll progress increases.
To accomplish this, we can use Framer Motion's useTransform
hook to transform the latest value of scrollYProgress
into it's inverse by subtracting it from 1
.
const endingShadowVisibility = useTransform( scrollYProgress, (latest) => 1 - latest);
Our updated hook now looks like:
import { useScroll, useTransform } from "framer-motion";type OptionsType = { ref: React.RefObject<HTMLElement>;};function useScrollShadows({ ref }: OptionsType) { const { scrollYProgress } = useScroll({ container: ref }); const startingShadowVisibility = scrollYProgress; const endingShadowVisibility = useTransform( scrollYProgress, (latest) => 1 - latest ); return [startingShadowVisibility, endingShadowVisibility] as const;}
Here is another visualization to help us understand the relationship of each value as we scroll an overflowing article:
startingShadowVisibility: 0.0
endingShadowVisibility: 1.0
And there we go! The values are updating as expected. So we're done right?
Look what happens once the container no longer has enough content to actually overflow:
startingShadowVisibility: 0.0
endingShadowVisibility: 1.0
Notice anything wrong? Try toggling the shadows on and off to reveal the issue.
"Wait, why is the starting shadow showing?"
When useScroll
is tracking an element that doesn't overflow, the
default value of scrollYProgress
is 1
. This means that our startingShadowVisibility
is also 1
and explains why we're seeing the starting shadow fully visible when there's no overflowing content.
This revelation will cause us to adjust our logic a bit, as we rely on the value increasing from 0
to 1
to control the visibility of the starting shadow, but if the value defaults to 1
(and never changes), the starting
shadow will always appear.
To account for this, we can use useTransform
to essentially override the default value for scroll containers that aren't overflowing:
const startingShadowVisibility = useTransform( scrollYProgress, (latest) => { const element = ref.current; if (element === null) return latest; const isOverflowing = element.scrollHeight > element.clientHeight; if (isOverflowing) { return latest; // preserve existing behavior } else { return 0; // override the default value } });
With that change, we've corrected the edge case of a container that doesn't overflow and we have a working implementation:
startingShadowVisibility: 0.0
endingShadowVisibility: 0.0
Notice both startingShadowVisibility
and endingShadowVisibility
are initialized to 0
when the container is not overflowing, and as a result our starting shadows are (correctly) fully hidden.
With that change, here's what our final code for useScrollShadows()
looks like:
import { useScroll, useTransform } from "framer-motion";type OptionsType = { ref: React.RefObject<HTMLElement>;};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 } else { return 0; // override the default value } } ); const endingShadowVisibility = useTransform( scrollXProgress, (latest) => 1 - latest ); return [startingShadowVisibility, endingShadowVisibility] as const;}
With our shadow logic set, we're ready to move onto the second half of the problem: adding the shadows to the page!
Positioning our shadows
Let's get started by using our useScrollShadows
hook that we created and connecting it to an overflowing element:
import { useRef } from "react";import { useScrollShadows } from "./use-scroll-shadows";import { cn } from "./utils";function ScrollShadows({ className, children,}: React.PropsWithChildren<{ className?: string }>) { const ref = useRef(null); const [startingShadowVisibility, endingShadowVisibility] = useScrollShadows({ ref }); return ( <div ref={ref} className={cn("overflow-y-auto", className)}> {children} </div> );}
I'll be using Tailwind to style my markup for these examples, along with a cn
helper popularized by shadcn/ui:
import { clsx } from "clsx";import { twMerge } from "tailwind-merge";import type { ClassValue } from "clsx";export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}
PS: I've included an extra section with Vanilla CSS at the end for those interested.
Taking inspiration from this article, a great strategy to position our shadows is by marking our elements with sticky
positioning at the beginning and end of our scrolling container and offsetting them with a negative margin to remove them from the document flow:
<div ref={ref} className={cn( "relative flex flex-col overflow-y-auto [--size:48px]", className )}> <div className="pointer-events-none sticky top-0 -mb-[--size] h-[--size] shrink-0 bg-blue-400/30" /> {children} <div className="pointer-events-none sticky bottom-0 -mt-[--size] h-[--size] shrink-0 bg-blue-400/30" /></div>
On the scrolling container we've added a CSS variable --size
and set it 48px
. We can use this value to both ensure uniform height
for our shadows as well as uniform negative margin.
Each shadow will be "stuck" to the overflowing container in their respective positions with position: sticky
and either top: 0
or bottom: 0
.
{ top: 0, height: 48px, marginBottom: -48px }
{ bottom: 0, height: 48px, marginTop: -48px }
size:
48px
And there we have it! Scroll shadows ready to add to your next project. Hope you enjoyed reading. If you only need to support vertical scrolling, you're good to go.
Final Code
import { useScroll, useTransform } from "framer-motion";type OptionsType = { ref: React.RefObject<HTMLElement>;};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 } else { return 0; // override the default value } } ); const endingShadowVisibility = useTransform( scrollXProgress, (latest) => 1 - latest ); return [startingShadowVisibility, endingShadowVisibility] as const;}
function ScrollShadows({ className, children,}: React.PropsWithChildren<{ className?: string;}>) { const ref = useRef<HTMLDivElement>(null); const [startingShadowVisibility, endingShadowVisibility] = useScrollShadows({ ref }); return ( <div ref={ref} className={cn( "group relative flex flex-col overflow-y-auto", className )} > <motion.div style={{ opacity: startingShadowVisibility }} className="pointer-events-none sticky top-0 -mb-[--size] h-[--size] shrink-0 bg-blue-400/30" /> {children} <motion.div style={{ opacity: endingShadowVisibility }} className="pointer-events-none sticky bottom-0 -mt-[--size] h-[--size] shrink-0 bg-blue-400/30" /> </div> );}
But if you're curious about how to extend our implementation to support horizontal scrolling...
Extra Credit
The first thing we'll have to adjust is our useScrollShadows
hook. Let's add an option to specify the orientation of the scrolling container by adding an axis
configuration:
type AxisType = "x" | "y";type OptionsType = { ref: React.RefObject<HTMLElement>; axis: AxisType;};function useScrollShadows({ ref, axis }: OptionsType) { // rest of code}
Now that our hook has a way to distinguish the intended axis
, let's extract our original useScroll
usage into a new hook which we'll call useScrollProgress
. It will be responsible for organizing our branching code between the different orientations.
import { useScroll } from "framer-motion";type AxisType = "x" | "y";type OptionsType = { ref: React.RefObject<HTMLElement>; axis: AxisType;};function useScrollShadows({ ref, axis }: OptionsType) { const { scrollProgress } = useScrollProgress({ ref, axis }); // rest of code}function useScrollProgress({ ref, axis }: OptionsType) { const { scrollXProgress, scrollYProgress } = useScroll({ container: ref, }); if (axis === "x") { return { scrollProgress: scrollXProgress, }; } else { return { scrollProgress: scrollYProgress, }; }}
For now, it doesn't do much besides conveniently returning the correct scrollProgress
value for the axis
that was passed in. But we still have to address how we are calculating the isOverflowing
variable, which we were previously doing like this:
const isOverflowing = element.scrollHeight > element.clientHeight;
We'll have to update this value to become contextually aware of the axis
and the correct way to measure. We can colocate this measurement in the same place we're returning scrollProgress
by updating isOverflowing
to become a function instead — which will accept the DOM node and return the correct measurement:
function useScrollProgress({ ref, axis }: OptionsType) { const { scrollXProgress, scrollYProgress } = useScroll({ container: ref, }); if (axis === "x") { return { scrollProgress: scrollXProgress, isOverflowing: (element: HTMLElement) => { return element.scrollWidth > element.clientWidth; }, }; } else { return { scrollProgress: scrollYProgress, isOverflowing: (element: HTMLElement) => { return element.scrollHeight > element.clientHeight; }, }; }}
And with that, we have our first update to support horizontal scrolling:
function useScrollShadows({ ref, axis }: OptionsType) { const { scrollProgress, isOverflowing } = useScrollProgress({ ref, axis, }); const startingShadowVisibility = useTransform( scrollProgress, (latest) => { const element = ref.current; if (element === null) return latest; if (isOverflowing(element)) { return latest; // preserve existing behavior } else { return 0; // override default behavior } } ); const endingShadowVisibility = useTransform( scrollProgress, (latest) => 1 - latest ); return [startingShadowVisibility, endingShadowVisibility] as const;}
Next up will be updating <ScrollShadows />
to accommodate the new axis
.
The first thing we'll do is pass axis
to our scrolling container as a data attribute in order to conditionally apply certain styles to our container:
function ScrollShadows({ className, axis, children,}: React.PropsWithChildren<{ className?: string; axis: AxisType;}>) { const ref = useRef<HTMLDivElement>(null); const [startingShadowVisibility, endingShadowVisibility] = useScrollShadows({ ref, axis }); return ( <div ref={ref} data-axis={axis} className={cn( "group relative flex", "data-[axis=x]:flex-row data-[axis=x]:overflow-x-auto", // horizontal styling "data-[axis=y]:flex-col data-[axis=y]:overflow-y-auto", // vertical styling className )} > <motion.div style={{ opacity: startingShadowVisibility }} className="pointer-events-none sticky top-0 -mb-[--size] h-[--size] shrink-0 bg-blue-400/30" /> {children} <motion.div style={{ opacity: endingShadowVisibility }} className="pointer-events-none sticky bottom-0 -mt-[--size] h-[--size] shrink-0 bg-blue-400/30" /> </div> );}
With this update, we're instructing our scrolling container to adjust it's flex-direction
and overflow
direction based on the axis
variable passed in.
Next, let's update our scrolling shadows to also conditionally place themselves in the right position:
import { useRef } from "react";import { motion } from "framer-motion";import { useScrollShadows } from "./use-scroll-shadows";function ScrollShadows({ axis, className, children,}: React.PropsWithChildren<{ axis: "x" | "y"; className?: string;}>) { const ref = useRef<HTMLDivElement>(null); const [startingShadowVisibility, endingShadowVisibility] = useScrollShadows({ ref, axis }); return ( <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", className )} > <motion.div style={{ opacity: startingShadowVisibility }} className={cn( "pointer-events-none sticky shrink-0 bg-blue-400/30", "group-data-[axis=x]:bottom-0 group-data-[axis=x]:left-0 group-data-[axis=x]:top-0 group-data-[axis=x]:-mr-[--size] group-data-[axis=x]:w-[--size]", // horizontal styles "group-data-[axis=y]:top-0 group-data-[axis=y]:-mb-[--size] group-data-[axis=y]:h-[--size]" // vertical styles )} /> {children} <motion.div style={{ opacity: endingShadowVisibility }} className={cn( "pointer-events-none sticky shrink-0 bg-blue-400/30", "group-data-[axis=x]:bottom-0 group-data-[axis=x]:right-0 group-data-[axis=x]:top-0 group-data-[axis=x]:-ml-[--size] group-data-[axis=x]:w-[--size]", // horizontal styles "group-data-[axis=y]:bottom-0 group-data-[axis=y]:-mt-[--size] group-data-[axis=y]:h-[--size]" // vertical styles )} /> </div> );}
And with that change, we've adapted our component to support both vertical and horizontal scrolling!
import { useRef } from "react";import { motion } from "framer-motion";import { useScrollShadows } from "./use-scroll-shadows";function ScrollShadows({ axis, className, children,}: React.PropsWithChildren<{ axis: "x" | "y"; className?: string;}>) { const ref = useRef<HTMLDivElement>(null); const [startingShadowVisibility, endingShadowVisibility] = useScrollShadows({ ref, axis }); return ( <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", className )} > <motion.div style={{ opacity: startingShadowVisibility }} className={cn( "pointer-events-none sticky shrink-0 bg-blue-400/30", "group-data-[axis=x]:bottom-0 group-data-[axis=x]:left-0 group-data-[axis=x]:top-0 group-data-[axis=x]:-mr-[--size] group-data-[axis=x]:w-[--size]", "group-data-[axis=y]:top-0 group-data-[axis=y]:-mb-[--size] group-data-[axis=y]:h-[--size]" )} /> {children} <motion.div style={{ opacity: endingShadowVisibility }} className={cn( "pointer-events-none sticky shrink-0 bg-blue-400/30", "group-data-[axis=x]:bottom-0 group-data-[axis=x]:right-0 group-data-[axis=x]:top-0 group-data-[axis=x]:-ml-[--size] group-data-[axis=x]:w-[--size]", "group-data-[axis=y]:bottom-0 group-data-[axis=y]:-mt-[--size] group-data-[axis=y]:h-[--size]" )} /> </div> );}
// use-scroll-shadows.tsximport { useScroll, useTransform } from "framer-motion";type AxisType = "x" | "y";type OptionsType = { ref: React.RefObject<HTMLElement>; axis: AxisType;};export function useScrollShadows({ ref, axis }: OptionsType) { const { scrollProgress, isOverflowing } = useScrollProgress({ ref, axis, }); const startingShadowVisibility = useTransform( scrollProgress, (latest) => { const element = ref.current; if (element === null) return latest; if (isOverflowing(element)) { return latest; // preserve existing behavior } else { return 0; // override default behavior } } ); const endingShadowVisibility = useTransform( scrollProgress, (latest) => 1 - latest ); return [startingShadowVisibility, endingShadowVisibility] as const;}function useScrollProgress({ ref, axis }: OptionsType) { const { scrollXProgress, scrollYProgress } = useScroll({ container: ref, }); if (axis === "x") { return { scrollProgress: scrollXProgress, isOverflowing: (element: HTMLElement) => { return element.scrollWidth > element.clientWidth; }, }; } else { return { scrollProgress: scrollYProgress, isOverflowing: (element: HTMLElement) => { return element.scrollHeight > element.clientHeight; }, }; }}
And, as promised:
Vanilla CSS
.scroll-shadow-root { --size: 25px; position: relative; display: flex; .scroll-shadow-start, .scroll-shadow-end { pointer-events: none; position: sticky; flex-shrink: 0; } &[data-axis="x"] { flex-direction: row; overflow-x: auto; .scroll-shadow-start { bottom: 0; left: 0; top: 0; margin-right: calc(var(--size) * -1); width: var(--size); } .scroll-shadow-end { bottom: 0; right: 0; top: 0; margin-left: calc(var(--size) * -1); width: var(--size); } } &[data-axis="y"] { flex-direction: column; overflow-y: auto; .scroll-shadow-start { top: 0; height: var(--size); margin-bottom: calc(var(--size) * -1); } .scroll-shadow-end { bottom: 0; margin-top: calc(var(--size) * -1); height: var(--size); } }}