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:
| Variable | Default | Description |
|---|---|---|
--scroll-shadow-size | 40px | Controls the fade gradient size. This is set from the size prop. |
--scroll-shadow-offset | 0px | How far the container must be scrolled before the fade starts. This is set from the offset prop. |
--scroll-shadow-scrollbar-size | 10px (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"whenvisibilityis controlled orisEnabledisfalse
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
automode the root always resolves amask-image, even when there is nothing to scroll. That makes it a stacking context and a containing block forposition: fixeddescendants. Setvisibilityexplicitly if you need to opt out. - The scroll-driven fade uses the
animationproperty on the root. Applying ananimate-*utility to the same element replaces it and leaves no fade. Animate a wrapper instead.
API Reference
ScrollShadow
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "vertical" | "horizontal" | "vertical" | The scroll direction |
variant | "fade" | "fade" | The visual shadow effect style |
size | number | 40 | The shadow gradient size in pixels |
offset | number | 0 | The scroll offset before showing shadows (in pixels) |
hideScrollBar | boolean | false | Whether to hide the native scrollbar |
isEnabled | boolean | true | Whether 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 |
className | string | - | Additional CSS classes to apply to the root element |
children | ReactNode | - | The scrollable content |