React

A deep dive into my React scroll-fade gradient hook

Elevate your UI with a custom React hook! A deep dive into creating a beautiful, reusable scroll-fade gradient effect from scratch using CSS and JavaScript.

A

Alex Carter

Senior Frontend Engineer specializing in React, performance, and delightful user interfaces.

7 min read24 views

Have you ever been scrolling through a long list or a block of text on a website, and it just... ends? The content is abruptly cut off at the top or bottom of its container, leaving you with a jarring hard edge. It’s a small detail, but it’s one of those user interface quirks that can make an otherwise polished application feel slightly unfinished. It lacks the affordance to tell you, “Hey, there’s more to see here!”

This is a problem I’ve encountered countless times, and for a while, I patched it with component-specific logic. But that’s not a scalable or elegant solution. What we really need is a clean, reusable, and declarative way to add a subtle fade effect to the edges of a scrollable container, indicating that more content lies beyond the fold. That’s why I created useScrollFade, a custom React hook that gracefully handles this exact scenario.

In this deep dive, we’re going to build this hook from the ground up. We’ll explore the simple-yet-powerful CSS that makes it possible, write the React logic to dynamically apply it, and package it all into a flexible custom hook that you can drop into any project. Let’s get scrolling!

The UX Problem: Why Hard Edges Feel Wrong

Great user experience is often invisible. It’s when things work so intuitively that you don’t even notice them. Conversely, poor UX, even in small doses, creates friction. A scrollable area without any visual cues is a perfect example. A user might not immediately realize a section is scrollable, or they might reach the bottom and not be sure if it’s a loading delay or the absolute end of the content.

A gradient fade solves this by providing a subtle, non-intrusive visual cue. It softly vignettes the content at the edge, suggesting continuity. When you see a fade at the bottom, you instinctively know you can scroll down. When it disappears, you know you’ve reached the end. It’s a simple, elegant solution to a common UI challenge.

The Goal: A Visual Guide for Scrolling

Our objective is to create a component that behaves as follows:

  • When the content is scrollable and the user is at the very top, a fade-out gradient appears at the bottom.
  • When the user scrolls down, a fade-in gradient appears at the top.
  • If the user is scrolled somewhere in the middle, gradients appear at both the top and bottom.
  • When the user reaches the very bottom, the bottom gradient disappears.

This dynamic behavior needs to be encapsulated and reusable, which makes it a perfect candidate for a custom hook.

The CSS Foundation: Mastering the Mask

Before we touch any JavaScript, let’s understand the CSS magic that powers this effect. We won’t be using oversized, pseudo-element gradients that sit on top of the content. That approach can be buggy and interfere with user interactions. Instead, we’ll use the brilliant mask-image property.

The mask-image property allows you to use a gradient (or an image) to define which parts of an element are visible. Where the mask is opaque, the element is visible. Where it's transparent, the element is hidden. This is perfect for our needs.

Advertisement

Here’s the core CSS. We'll define a base class for our scrollable container and modifier classes that our React hook will toggle.


.scrollable-container {
  --fade-height: 30px; /* Control the size of the fade */
  position: relative;
  overflow-y: auto;
}

.scrollable-container.fade-top {
  mask-image: linear-gradient(
    to bottom,
    transparent 0,
    black var(--fade-height),
    black calc(100% - var(--fade-height)),
    black 100%
  );
}

.scrollable-container.fade-bottom {
  mask-image: linear-gradient(
    to bottom,
    black 0,
    black calc(100% - var(--fade-height)),
    transparent 100%
  );
}

.scrollable-container.fade-top.fade-bottom {
  mask-image: linear-gradient(
    to bottom,
    transparent 0,
    black var(--fade-height),
    black calc(100% - var(--fade-height)),
    transparent 100%
  );
}
  

This CSS sets up three states. Note that we need a combined class (.fade-top.fade-bottom) to handle the middle-scroll case. The gradient transitions from transparent (hidden) to black (visible), effectively creating our fade effect without adding any extra elements.

Building the React Hook: useScrollFade

Now for the main event. We’ll create a file named useScrollFade.js and build our hook step by step.

Step 1: The Setup - Refs and State

Our hook needs two things to start: a reference to the DOM element we want to monitor and state to track whether the top and bottom fades should be active.


import { useState, useRef, useCallback } from 'react';

export const useScrollFade = () => {
  const [isFadedTop, setIsFadedTop] = useState(false);
  const [isFadedBottom, setIsFadedBottom] = useState(false);
  const ref = useRef(null);

  // ... logic will go here ...

  return { ref, isFadedTop, isFadedBottom };
};
  

We initialize isFadedTop to false because we start at the top. We also initialize isFadedBottom to false and will update it once the component mounts and we can measure the content.

Step 2: The Logic - Handling the Scroll Event

The core logic lives in a function that will be called whenever the user scrolls. This function needs to measure the element's scroll position and dimensions to decide which fades to show.

The three key properties of a DOM element we need are:

  • scrollTop: The number of pixels the content is scrolled from the top.
  • scrollHeight: The total height of the content, including the part not visible.
  • clientHeight: The visible height of the container.

With these, we can determine the scroll state.


const handleScroll = useCallback(() => {
  const el = ref.current;
  if (!el) return;

  // Add a 1px buffer for floating point inaccuracies
  const isAtTop = el.scrollTop <= 1;
  const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 1;

  setIsFadedTop(!isAtTop);
  setIsFadedBottom(!isAtBottom);
}, []);
  

We wrap our logic in useCallback to ensure the function reference is stable between renders, which is a good practice when dealing with event listeners in useEffect.

Step 3: Bringing it Together with useEffect

We need to attach our handleScroll function to the scroll event of our element. We also need to run it once initially to set the correct state when the component first renders. The useEffect hook is perfect for this.


import { useState, useRef, useCallback, useEffect } from 'react';

// ... inside useScrollFade hook ...

useEffect(() => {
  const el = ref.current;
  if (!el) return;

  // Check initial state
  const hasOverflow = el.scrollHeight > el.clientHeight;
  setIsFadedBottom(hasOverflow);
  handleScroll();

  el.addEventListener('scroll', handleScroll);

  // Clean up the event listener on unmount
  return () => {
    el.removeEventListener('scroll', handleScroll);
  };
}, [handleScroll]);
  

This effect does a few important things:

  1. It waits for the element (ref.current) to be available.
  2. It checks if the content is overflowing to begin with. If not, no fade is needed. If it is, we show the bottom fade.
  3. It runs handleScroll once to set the initial top/bottom state correctly.
  4. It adds the scroll event listener.
  5. Crucially, it returns a cleanup function to remove the event listener when the component unmounts, preventing memory leaks.

Step 4: The Final Hook

Let's assemble all the pieces into our final, complete hook.


// hooks/useScrollFade.js
import { useState, useRef, useCallback, useEffect } from 'react';

export const useScrollFade = () => {
  const [isFadedTop, setIsFadedTop] = useState(false);
  const [isFadedBottom, setIsFadedBottom] = useState(false);
  const ref = useRef(null);

  const handleScroll = useCallback(() => {
    const el = ref.current;
    if (!el) return;

    // Using a 1px buffer to be safe from float inaccuracies
    const isAtTop = el.scrollTop <= 1;
    const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 1;

    setIsFadedTop(!isAtTop);
    setIsFadedBottom(!isAtBottom && el.scrollHeight > el.clientHeight);
  }, []);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    // Initial check
    handleScroll();

    el.addEventListener('scroll', handleScroll, { passive: true });

    // Also check on resize, as content/container size might change
    const resizeObserver = new ResizeObserver(handleScroll);
    resizeObserver.observe(el);

    return () => {
      el.removeEventListener('scroll', handleScroll);
      resizeObserver.unobserve(el);
    };
  }, [handleScroll]);

  return { ref, isFadedTop, isFadedBottom };
};
  

Update: I've added a ResizeObserver to the final hook. This makes it more robust, as it will re-calculate the fades if the container or its content changes size, a common scenario in responsive layouts.

Putting It to Use: An Example Component

Using the hook is now incredibly simple and declarative. We import it, call it in our component, and spread the returned props onto our element.

First, let's update our CSS to use data attributes for a slightly cleaner approach that doesn't rely on class name concatenation.


.scrollable-container {
  --fade-height: 30px;
  mask-image: linear-gradient(to bottom, black 0, black 100%);
}

.scrollable-container[data-fade-top='true'] {
  mask-image: linear-gradient(
    to bottom,
    transparent 0,
    black var(--fade-height),
    black 100%
  );
}

.scrollable-container[data-fade-bottom='true'] {
  mask-image: linear-gradient(
    to bottom,
    black 0,
    black calc(100% - var(--fade-height)),
    transparent 100%
  );
}

.scrollable-container[data-fade-top='true'][data-fade-bottom='true'] {
  mask-image: linear-gradient(
    to bottom,
    transparent 0,
    black var(--fade-height),
    black calc(100% - var(--fade-height)),
    transparent 100%
  );
}
  

And here is the React component:


import React from 'react';
import { useScrollFade } from './hooks/useScrollFade';
import './styles.css'; // Assuming the CSS above is in this file

const MyScrollableListComponent = () => {
  const { ref, isFadedTop, isFadedBottom } = useScrollFade();

  return (
    
{[...Array(50)].map((_, i) => (

Item number {i + 1}

))}
); }; export default MyScrollableListComponent;

Look at how clean that is! The complex logic of tracking scroll position is completely abstracted away. Our component only needs to know about the state (isFadedTop, isFadedBottom) and how to apply it. This is the power of custom hooks in action.

Conclusion: The Power of Reusable UI Logic

We’ve successfully built a powerful, reusable React hook that solves a common UI problem with elegance. By combining the CSS mask-image property with React's state, refs, and effect hooks, we created a solution that is both performant and incredibly easy to use.

This pattern—identifying a piece of complex, stateful UI logic and extracting it into a hook—is fundamental to writing modern, maintainable React applications. It keeps your components clean, declarative, and focused on their primary purpose: rendering the UI. So next time you see a jarring hard edge on a scrollable list, you’ll know exactly how to fade it away.

Tags

You May Also Like