Add scroll-fade gradients in React with this one simple hook
Tired of harsh content edges? Learn how to add elegant scroll-fade gradients in React with a simple, reusable custom hook. Elevate your UI in minutes!
Alex Ivanov
A frontend developer passionate about creating seamless user experiences with React and CSS.
Ever crafted the perfect UI, only to have it slightly undermined by a long list or text block that just... ends? That hard, abrupt edge where content gets cut off can feel a bit jarring. It breaks the flow and doesn't give users a visual cue that more content is waiting to be scrolled into view.
What if you could effortlessly signal that overflow? Enter the scroll-fade gradient: a subtle, elegant effect that fades content out at the edges of a scrollable container. It’s a small touch that makes a world of difference in user experience. Today, we're going to build a simple, reusable React hook to achieve this effect with minimal fuss.
The Problem: Abrupt Content Edges
Imagine a modal with terms and conditions, a chat window, or a dropdown menu with many options. When the content exceeds the container's height, the browser simply clips it. This isn't a bug, but it’s not great design. The user has no immediate visual indication that scrolling is required, apart from a scrollbar that might be styled to be unobtrusive or even hidden on some operating systems.
A fade-out effect at the bottom (and top, once scrolled) provides that missing piece of information, gently guiding the user and making the interface feel more intuitive and polished.
The Static Solution: A Pure CSS Approach
Before we dive into React, it's worth knowing that you can create a static version of this effect with just CSS. The magic ingredient is `mask-image`, which lets you use a gradient as a visibility mask.
You could apply a style like this to your scrollable container:
.scroll-container {
overflow-y: auto;
height: 200px;
-webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
}
This works! It creates a nice fade at the bottom. But it has a major flaw: the fade is always there. When the user scrolls all the way to the bottom, the last few lines of text are still faded, which is misleading. We need a dynamic solution that reacts to the scroll position.
Making It Dynamic: The `useScrollFade` Hook
This is where React's custom hooks shine. We can encapsulate all the logic for tracking scroll position into a clean, reusable function. Our goal is to create a hook, `useScrollFade`, that tells our component whether it's scrolled to the top, the bottom, or somewhere in the middle.
The Hook's Logic
Here’s the plan for our hook:
- It will accept a `ref` to the scrollable DOM element.
- It will use `useState` to store the current fade state (e.g., 'top', 'middle', 'bottom').
- It will use `useEffect` to attach a scroll event listener to the element when the component mounts.
- The event handler will calculate the scroll position and update the state accordingly.
- Crucially, it will return a cleanup function from `useEffect` to remove the event listener, preventing memory leaks.
The Complete Hook Code
Create a new file, `useScrollFade.js`, and add the following code. It's surprisingly concise!
import { useState, useEffect, useCallback } from 'react';
export const useScrollFade = (ref) => {
const [fadeState, setFadeState] = useState('is-top');
const handleScroll = useCallback(() => {
const el = ref.current;
if (!el) return;
// A small buffer to prevent flickering at the edges
const buffer = 1;
const isAtTop = el.scrollTop <= buffer;
const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + buffer;
if (isAtTop && !isAtBottom) {
setFadeState('is-top');
} else if (!isAtTop && isAtBottom) {
setFadeState('is-bottom');
} else if (!isAtTop && !isAtBottom) {
setFadeState('is-middle');
} else {
// Content is not scrollable or fits perfectly
setFadeState('is-static');
}
}, [ref]);
useEffect(() => {
const el = ref.current;
if (!el) return;
// Initial check on mount
handleScroll();
el.addEventListener('scroll', handleScroll, { passive: true });
// Also listen for resize events, as this can affect scrollability
const resizeObserver = new ResizeObserver(handleScroll);
resizeObserver.observe(el);
return () => {
el.removeEventListener('scroll', handleScroll);
resizeObserver.unobserve(el);
};
}, [ref, handleScroll]);
return fadeState;
};
Putting the Hook to Work
Now that we have our powerful little hook, using it is a two-step process: wire it up in our component and add the corresponding CSS classes.
1. The React Component
In your component file, import the hook and a `useRef`. Attach the ref to your scrollable element and use the state returned by the hook to apply a dynamic class.
import React, { useRef } from 'react';
import { useScrollFade } from './useScrollFade';
import './ScrollableContent.css'; // We'll create this next
const ScrollableContent = () => {
const scrollContainerRef = useRef(null);
const fadeState = useScrollFade(scrollContainerRef);
return (
<div
ref={scrollContainerRef}
className={`scroll-container ${fadeState}`}
>
<p>This is some long content that will definitely cause the container to scroll. We're adding multiple paragraphs to ensure there's enough text to demonstrate the effect properly.</p>
<p>As you scroll down, you'll notice the top edge begins to fade in, indicating that you can scroll back up. The bottom edge, which was faded, will become solid as you approach the end of the content.</p>
<p>This provides a much better user experience than a hard cut-off. It's a subtle but powerful visual cue that makes the interface feel more responsive and thoughtfully designed.</p>
<p>The custom hook we built handles all the complex logic, keeping our component clean and declarative. We simply get a state ('is-top', 'is-middle', 'is-bottom') and apply a class. Simple!</p>
<p>This is the final paragraph. When you see this fully, the bottom fade should be gone, and the top fade should be visible, letting you know you've reached the end but can scroll back up.</p>
</div>
);
};
export default ScrollableContent;
2. The Supporting CSS
This is where the visual magic happens. We define the base styles and then the different mask gradients for each state.
.scroll-container {
height: 250px;
overflow-y: auto;
background-color: #f0f4f8;
padding: 1rem;
border-radius: 8px;
transition: -webkit-mask-image 0.2s ease-in-out, mask-image 0.2s ease-in-out;
}
/* No fade when content isn't scrollable */
.scroll-container.is-static {
-webkit-mask-image: none;
mask-image: none;
}
/* Scrolled to the top: fade bottom only */
.scroll-container.is-top {
-webkit-mask-image: linear-gradient(to bottom, black 90%, transparent 100%);
mask-image: linear-gradient(to bottom, black 90%, transparent 100%);
}
/* Scrolled to the bottom: fade top only */
.scroll-container.is-bottom {
-webkit-mask-image: linear-gradient(to top, black 90%, transparent 100%);
mask-image: linear-gradient(to top, black 90%, transparent 100%);
}
/* Scrolled to the middle: fade both */
.scroll-container.is-middle {
-webkit-mask-image: linear-gradient(to bottom,
transparent 0%,
black 10%,
black 90%,
transparent 100%
);
mask-image: linear-gradient(to bottom,
transparent 0%,
black 10%,
black 90%,
transparent 100%
);
}
And that’s it! You now have a fully dynamic, reusable scroll-fade effect.
Comparing the Approaches
Let's quickly see why the custom hook approach is a winner.
Method | Pros | Cons | Best For |
---|---|---|---|
Static CSS | Extremely simple, no JS required. | Not dynamic. Fades content even when fully scrolled. | Decorative elements that don't need to be fully readable. |
Inline Component Logic | Works and is dynamic. | Clutters component, not reusable, mixes concerns. | A one-off implementation where reusability isn't a concern. |
Custom Hook (`useScrollFade`) | Reusable, declarative, separates concerns, keeps components clean. | Slightly more initial setup (creating the hook file). | Any application where you'll need this effect in more than one place. The clear winner for scalable projects. |
Under the Hood: How the Hook Works
The core of the hook's logic lies in this line: `el.scrollHeight - el.scrollTop <= el.clientHeight + buffer;`
Let's break it down:
el.scrollHeight
: The total height of the element's content, including the parts not visible due to overflow.el.scrollTop
: The number of pixels the content is scrolled from the top.el.clientHeight
: The visible height of the element on the screen.
So, the expression `scrollHeight - scrollTop` gives us the height of the remaining content (including the visible part). When this value is equal to the `clientHeight`, it means we've scrolled exactly to the bottom. We add a small `buffer` to account for sub-pixel rendering and avoid a flickering effect right at the edge.
The `useCallback` hook is used to memoize the `handleScroll` function, ensuring it isn't recreated on every render unless its dependency (`ref`) changes. This is a performance optimization that prevents the `useEffect` from running unnecessarily.
Final Thoughts & Key Takeaways
We've taken a common UI problem and created a robust, elegant, and reusable solution. By leveraging the power of React's custom hooks, we've successfully separated our UI logic from our component's presentation.
Key Takeaways:
- Enhance UX: Scroll-fades provide crucial visual feedback in scrollable containers.
- Separate Concerns: Custom hooks are perfect for extracting complex logic out of components, making them cleaner and more focused.
- CSS is Powerful: The `mask-image` property is a modern and efficient way to create these kinds of visual effects, driven by JavaScript.
Next time you're building a component with overflow, give this hook a try. It’s a small addition that adds a significant layer of polish to your application's interface.