Skip to content
Lenso UI

ScrollShadow

Apply visual shadows to indicate scrollable content overflow with automatic detection of scroll position.

Usage

import { ScrollShadow } from "@lenso/ui";
"use client";
import { ScrollShadow } from "@lenso/ui";import * as stylex from "@stylexjs/stylex";
const styles = stylex.create({  root: { width: "100%", maxWidth: { default: null, "@media (min-width: 640px)": 384 } },  scroll: { maxHeight: 240, padding: 16 },  paragraphs: { display: "flex", flexDirection: "column", gap: 16 },});export function Default() {  return (    <div {...stylex.props(styles.root)}>      <ScrollShadow tabIndex={0} aria-label="Scrollable sample text" xstyle={styles.scroll}>        <div {...stylex.props(styles.paragraphs)}>          {Array.from({ length: 10 }, (_, index) => (            <p key={index}>              Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam pulvinar risus non              risus hendrerit venenatis. Pellentesque sit amet hendrerit risus, sed porttitor quam.              Morbi accumsan cursus enim, sed ultricies sapien.            </p>          ))}        </div>      </ScrollShadow>    </div>  );}export default Default;

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Examples

Orientation

"use client";// Adapted from HeroUI v3.2.6 (Apache-2.0).import { Card, ScrollShadow } from "@lenso/ui";import * as stylex from "@stylexjs/stylex";const images = [  "https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/docs/robot1.jpeg",  "https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/docs/avocado.jpeg",  "https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/docs/oranges.jpeg",];const styles = stylex.create({  root: { width: "100%", maxWidth: { default: null, "@media (min-width: 640px)": 384 } },  vertical: { marginBottom: 32, width: "100%" },  heading: { marginBottom: 8, fontSize: 14, lineHeight: "20px", fontWeight: 600 },  card: { width: "100%", padding: 0 },  verticalScroll: { maxHeight: 240, padding: 16 },  horizontalScroll: { padding: 16 },  paragraphs: { display: "flex", flexDirection: "column", gap: 16 },  cards: { display: "flex", flexDirection: "row", gap: 16 },  item: { display: "flex", minWidth: 200, flexDirection: "row", gap: 12, padding: 4 },  image: {    aspectRatio: "1",    width: { default: 64, "@media (min-width: 640px)": 80 },    height: { default: 64, "@media (min-width: 640px)": 80 },    flexShrink: 0,    borderRadius: "var(--radius-xl)",    objectFit: "cover",    userSelect: "none",  },  text: { display: "flex", flex: 1, flexDirection: "column", justifyContent: "center", gap: 4 },  title: { fontSize: 14 },  description: { fontSize: 12 },});export default function Orientation() {  return (    <div {...stylex.props(styles.root)}>      <div {...stylex.props(styles.vertical)}>        <h4 {...stylex.props(styles.heading)}>Vertical</h4>        <Card xstyle={styles.card}>          <ScrollShadow            tabIndex={0}            aria-label="Vertical sample"            xstyle={styles.verticalScroll}            orientation="vertical"          >            <div {...stylex.props(styles.paragraphs)}>              {Array.from({ length: 10 }, (_, index) => (                <p key={index}>                  Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam pulvinar risus non                  risus hendrerit venenatis. Pellentesque sit amet hendrerit risus, sed porttitor                  quam. Morbi accumsan cursus enim, sed ultricies sapien.                </p>              ))}            </div>          </ScrollShadow>        </Card>      </div>      <div>        <h4 {...stylex.props(styles.heading)}>Horizontal</h4>        <Card xstyle={styles.card}>          <ScrollShadow            tabIndex={0}            aria-label="Horizontal cards"            xstyle={styles.horizontalScroll}            orientation="horizontal"          >            <div {...stylex.props(styles.cards)}>              {Array.from({ length: 10 }, (_, index) => (                <Card key={index} xstyle={styles.item} variant="transparent">                  <img                    alt="Lorem Card"                    {...stylex.props(styles.image)}                    loading="lazy"                    src={images[index % images.length]}                  />                  <div {...stylex.props(styles.text)}>                    <Card.Title xstyle={styles.title}>Bridging the Future</Card.Title>                    <Card.Description xstyle={styles.description}>Today, 6:30 PM</Card.Description>                  </div>                </Card>              ))}            </div>          </ScrollShadow>        </Card>      </div>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Shadow Size

"use client";// Adapted from HeroUI v3.2.6 (Apache-2.0).import { ScrollShadow } from "@lenso/ui";import * as stylex from "@stylexjs/stylex";const styles = stylex.create({  root: { width: "100%", maxWidth: { default: null, "@media (min-width: 640px)": 384 } },  scroll: { maxHeight: 240, padding: 16 },  paragraphs: { display: "flex", flexDirection: "column", gap: 16 },});export default function CustomSize() {  return (    <div {...stylex.props(styles.root)}>      <ScrollShadow        size={80}        tabIndex={0}        aria-label="Scrollable sample text"        xstyle={styles.scroll}      >        <div {...stylex.props(styles.paragraphs)}>          {Array.from({ length: 10 }, (_, index) => (            <p key={index}>              Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam pulvinar risus non              risus hendrerit venenatis. Pellentesque sit amet hendrerit risus, sed porttitor quam.              Morbi accumsan cursus enim, sed ultricies sapien.            </p>          ))}        </div>      </ScrollShadow>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

With Card

"use client";// Adapted from HeroUI v3.2.6 (Apache-2.0).import { Button, Card, ScrollShadow } from "@lenso/ui";import * as stylex from "@stylexjs/stylex";const styles = stylex.create({  card: { maxWidth: 400 },  content: { padding: 0 },  scroll: { height: 300, paddingInline: 16 },  paragraphs: { display: "flex", flexDirection: "column", gap: 16 },  footer: { marginTop: 16, display: "flex", flexDirection: "row", gap: 8 },  button: { width: "100%" },});export default function WithCard() {  return (    <Card xstyle={styles.card}>      <Card.Header>        <Card.Title>Terms and Conditions</Card.Title>        <Card.Description>Please review before proceeding</Card.Description>      </Card.Header>      <Card.Content xstyle={styles.content}>        <ScrollShadow          tabIndex={0}          aria-label="Terms and Conditions"          xstyle={styles.scroll}          size={80}        >          <div {...stylex.props(styles.paragraphs)}>            {Array.from({ length: 10 }, (_, index) => (              <p key={index}>                Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam pulvinar risus non                risus hendrerit venenatis. Pellentesque sit amet hendrerit risus, sed porttitor                quam. Morbi accumsan cursus enim, sed ultricies sapien.              </p>            ))}          </div>        </ScrollShadow>      </Card.Content>      <Card.Footer xstyle={styles.footer}>        <Button xstyle={styles.button} variant="secondary">          Cancel        </Button>        <Button xstyle={styles.button}>Accept</Button>      </Card.Footer>    </Card>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Hide Scroll Bar

"use client";// Adapted from HeroUI v3.2.6 (Apache-2.0).import { ScrollShadow } from "@lenso/ui";import * as stylex from "@stylexjs/stylex";const styles = stylex.create({  root: { width: "100%", maxWidth: { default: null, "@media (min-width: 640px)": 384 } },  scroll: { maxHeight: 240, padding: 16 },  paragraphs: { display: "flex", flexDirection: "column", gap: 16 },});export default function HideScrollBar() {  return (    <div {...stylex.props(styles.root)}>      <ScrollShadow        hideScrollBar        tabIndex={0}        aria-label="Scrollable sample text"        xstyle={styles.scroll}      >        <div {...stylex.props(styles.paragraphs)}>          {Array.from({ length: 10 }, (_, index) => (            <p key={index}>              Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam pulvinar risus non              risus hendrerit venenatis. Pellentesque sit amet hendrerit risus, sed porttitor quam.              Morbi accumsan cursus enim, sed ultricies sapien.            </p>          ))}        </div>      </ScrollShadow>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Visibility Change

"use client";// Adapted from HeroUI v3.2.6 (Apache-2.0).import { Card, ScrollShadow, type ScrollShadowVisibility } from "@lenso/ui";import { useState } from "react";import * as stylex from "@stylexjs/stylex";const images = [  "https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/docs/robot1.jpeg",  "https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/docs/avocado.jpeg",  "https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/docs/oranges.jpeg",];const styles = stylex.create({  root: { width: "100%", maxWidth: { default: null, "@media (min-width: 640px)": 384 } },  section: { display: "flex", flexDirection: "column", gap: 8 },  vertical: { marginBottom: 32 },  status: { borderRadius: "var(--radius)", backgroundColor: "var(--default)", padding: 16 },  statusText: { fontSize: 14, lineHeight: "20px", fontWeight: 600 },  verticalScroll: { maxHeight: 240, padding: 16 },  horizontalScroll: { padding: 16 },  paragraphs: { display: "flex", flexDirection: "column", gap: 16 },  cards: { display: "flex", flexDirection: "row", gap: 16 },  item: { display: "flex", minWidth: 200, flexDirection: "row", gap: 12, padding: 4 },  image: {    aspectRatio: "1",    width: { default: 64, "@media (min-width: 640px)": 80 },    height: { default: 64, "@media (min-width: 640px)": 80 },    flexShrink: 0,    borderRadius: "var(--radius-xl)",    objectFit: "cover",    userSelect: "none",  },  text: { display: "flex", flex: 1, flexDirection: "column", justifyContent: "center", gap: 4 },  title: { fontSize: 14 },  description: { fontSize: 12 },});export default function VisibilityChange() {  const [verticalState, setVerticalState] = useState<ScrollShadowVisibility>("none");  const [horizontalState, setHorizontalState] = useState<ScrollShadowVisibility>("none");  return (    <div {...stylex.props(styles.root)}>      <div {...stylex.props(styles.section, styles.vertical)}>        <div {...stylex.props(styles.status)}>          <p {...stylex.props(styles.statusText)}>Vertical Shadow State: {verticalState}</p>        </div>        <ScrollShadow          tabIndex={0}          aria-label="Vertical sample"          xstyle={styles.verticalScroll}          orientation="vertical"          onVisibilityChange={setVerticalState}        >          <div {...stylex.props(styles.paragraphs)}>            {Array.from({ length: 10 }, (_, index) => (              <p key={index}>                Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam pulvinar risus non                risus hendrerit venenatis. Pellentesque sit amet hendrerit risus, sed porttitor                quam. Morbi accumsan cursus enim, sed ultricies sapien.              </p>            ))}          </div>        </ScrollShadow>      </div>      <div {...stylex.props(styles.section)}>        <div {...stylex.props(styles.status)}>          <p {...stylex.props(styles.statusText)}>Horizontal Shadow State: {horizontalState}</p>        </div>        <ScrollShadow          tabIndex={0}          aria-label="Horizontal cards"          xstyle={styles.horizontalScroll}          orientation="horizontal"          onVisibilityChange={setHorizontalState}        >          <div {...stylex.props(styles.cards)}>            {Array.from({ length: 10 }, (_, index) => (              <Card key={index} xstyle={styles.item} variant="transparent">                <img                  alt="Lorem Card"                  {...stylex.props(styles.image)}                  loading="lazy"                  src={images[index % images.length]}                />                <div {...stylex.props(styles.text)}>                  <Card.Title xstyle={styles.title}>Bridging the Future</Card.Title>                  <Card.Description xstyle={styles.description}>Today, 6:30 PM</Card.Description>                </div>              </Card>            ))}          </div>        </ScrollShadow>      </div>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Customization

Tailwind CSS

"use client";// Adapted from HeroUI v3.2.6 (Apache-2.0).import { ScrollShadow } from "@lenso/ui";import * as stylex from "@stylexjs/stylex";const entries = [  "Reviewed quarterly goals with the design team.",  "Shipped dark mode tokens to production.",  "Merged accessibility fixes for form fields.",  "Published updated component documentation.",  "Scheduled performance audit for next sprint.",  "Added scroll shadow demos to the docs site.",];const styles = stylex.create({  root: { width: "100%", maxWidth: { default: null, "@media (min-width: 640px)": 384 } },  scroll: {    maxHeight: 192,    borderRadius: "var(--radius-xl)",    border: "1px solid color-mix(in oklab, var(--border) 80%, transparent)",    backgroundImage:      "linear-gradient(to bottom, light-dark(oklch(98.5% 0 0 / .9), oklch(20.5% 0 0 / .8)), light-dark(white, oklch(20.5% 0 0)))",    padding: 16,    boxShadow: "0 0 0 1px light-dark(rgb(0 0 0 / .05), rgb(255 255 255 / .1))",  },  entries: { display: "flex", flexDirection: "column", gap: 12 },  entry: {    fontSize: 14,    lineHeight: 1.625,    color: "light-dark(oklch(43.9% 0 0), oklch(70.8% 0 0))",  },});export function CustomStyles() {  return (    <div {...stylex.props(styles.root)}>      <ScrollShadow        hideScrollBar        tabIndex={0}        aria-label="Recent activity"        xstyle={styles.scroll}        size={48}        variant="fade"      >        <div {...stylex.props(styles.entries)}>          {entries.map((entry) => (            <p key={entry} {...stylex.props(styles.entry)}>              {entry}            </p>          ))}        </div>      </ScrollShadow>    </div>  );}export default CustomStyles;

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Global CSS

To customize the ScrollShadow component classes, you can use the @layer components directive. Learn more.

@layer components {  .scroll-shadow {    @apply rounded-xl border border-default-200;  }
  .scroll-shadow--vertical {    @apply pr-2; /* Add padding for custom scrollbar styling */  }
  .scroll-shadow--horizontal {    @apply pb-2;  }}

Styling Reference

HeroUI follows the BEM methodology to ensure component variants and states are reusable and easy to customize.

CSS Classes

The ScrollShadow component uses these CSS classes (View source styles):

Base Classes [!toc]

  • .scroll-shadow - Root container element

Orientation Variants [!toc]

  • .scroll-shadow--vertical - Vertical scrolling (default)
  • .scroll-shadow--horizontal - Horizontal scrolling

State Modifiers [!toc]

  • .scroll-shadow--hide-scrollbar - Hides native scrollbar

CSS Variables

The ScrollShadow component uses CSS variables to size the fade mask and reserve space for visible native scrollbars:

VariableDefaultDescription
--scroll-shadow-size40pxControls the fade gradient size. This is set from the size prop.
--scroll-shadow-offset0pxHow far the container must be scrolled before the fade starts. This is set from the offset prop.
--scroll-shadow-scrollbar-size10px (0px when hideScrollBar)Reserves a solid mask gutter for the native scrollbar so the fade does not cover it. Override for wider scrollbars.

Data Attributes

The component uses data attributes to control shadow visibility:

  • Scroll States: [data-top-scroll], [data-bottom-scroll], [data-left-scroll], [data-right-scroll] - Applied when content can be scrolled in that direction
  • Combined States: [data-top-bottom-scroll], [data-left-right-scroll] - Applied when content can be scrolled in both directions
  • Orientation: [data-orientation="vertical"] or [data-orientation="horizontal"] - Indicates scroll direction
  • Size: [data-scroll-shadow-size] - Contains the shadow gradient size value
  • Shadow Mode: [data-scroll-shadow-mode] - "auto" when the fade is derived from the scroll position, "manual" when visibility is controlled or isEnabled is false

Scroll-Driven Fade

In auto mode, browsers that support scroll-driven animations derive the fade from the scroll position in CSS. The mask is therefore correct on the very first paint, with no measurement and no flash of unfaded content during hydration. Browsers without support fall back to the [data-*-scroll] attributes above, which are written after hydration. Two things to keep in mind when customizing:

  • In auto mode the root always resolves a mask-image, even when there is nothing to scroll. That makes it a stacking context and a containing block for position: fixed descendants. Set visibility explicitly if you need to opt out.
  • The scroll-driven fade uses the animation property on the root. Applying an animate-* utility to the same element replaces it and leaves no fade. Animate a wrapper instead.

API Reference

ScrollShadow

PropTypeDefaultDescription
orientation"vertical" | "horizontal""vertical"The scroll direction
variant"fade""fade"The visual shadow effect style
sizenumber40The shadow gradient size in pixels
offsetnumber0The scroll offset before showing shadows (in pixels)
hideScrollBarbooleanfalseWhether to hide the native scrollbar
isEnabledbooleantrueWhether scroll shadow detection is enabled
visibility"auto" | "both" | "top" | "bottom" | "left" | "right" | "none""auto"Controlled shadow visibility state
onVisibilityChange(visibility: ScrollShadowVisibility) => void-Callback invoked when shadow visibility changes
classNamestring-Additional CSS classes to apply to the root element
childrenReactNode-The scrollable content