Skip to content

Scroll Patterns

Learn how to implement polished scrollable content with fade indicators, custom scrollbars, and smooth user experiences.

Overview

Katalyst provides scroll components for:

  • ScrollFade: Gradient overlays indicating more content
  • ScrollFadeContainer: All-in-one wrapper with automatic detection
  • SimpleBar integration: Custom scrollbars
  • Virtual scrolling: For large datasets

Basic Patterns

Scrollable Card with Fades

The most common pattern - a card with scrollable content and fade indicators:

tsx
import { ScrollFadeContainer } from '@/shared/ui/components/scroll/ScrollFadeContainer'
import { Card, CardHeader, CardTitle, CardContent } from '@/shadcn/components/ui/card'
import SimpleBar from 'simplebar-react'

function ActivityFeed({ activities }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle>Recent Activity</CardTitle>
      </CardHeader>
      <CardContent>
        <ScrollFadeContainer
          fadeGradient={{
            from: 'hsl(var(--card))',
          }}
        >
          <SimpleBar className="h-[400px]">
            <div className="space-y-3 pr-4">
              {activities.map((activity) => (
                <div key={activity.id} className="p-3 bg-muted rounded-md">
                  <p className="text-sm">{activity.message}</p>
                  <p className="text-xs text-muted-foreground">
                    {activity.timestamp}
                  </p>
                </div>
              ))}
            </div>
          </SimpleBar>
        </ScrollFadeContainer>
      </CardContent>
    </Card>
  )
}

Scrollable Sidebar

Common in dashboards - a fixed-height sidebar with scrollable content:

tsx
import { ScrollFadeContainer } from '@/shared/ui/components/scroll/ScrollFadeContainer'
import SimpleBar from 'simplebar-react'

function ShipmentSidebar({ shipments }) {
  return (
    <aside className="h-screen border-r">
      <div className="p-4 border-b">
        <h2 className="font-semibold">Shipments</h2>
      </div>
      
      <ScrollFadeContainer className="h-full">
        <SimpleBar className="h-[calc(100vh-80px)]">
          <div className="p-4 space-y-2">
            {shipments.map((shipment) => (
              <button
                key={shipment.id}
                className="w-full text-left p-3 rounded-lg hover:bg-accent"
              >
                <p className="font-medium">{shipment.id}</p>
                <p className="text-sm text-muted-foreground">
                  {shipment.status}
                </p>
              </button>
            ))}
          </div>
        </SimpleBar>
      </ScrollFadeContainer>
    </aside>
  )
}

Scrollable Dropdown

For dropdowns with many options:

tsx
import { ScrollFadeContainer } from '@/shared/ui/components/scroll/ScrollFadeContainer'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
} from '@/shadcn/components/ui/dropdown-menu'

function LongDropdown({ items }) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger>Select Item</DropdownMenuTrigger>
      <DropdownMenuContent>
        <ScrollFadeContainer
          fadeSize="sm"
          fadeGradient={{
            from: 'hsl(var(--popover))',
          }}
        >
          <div className="max-h-[300px] overflow-y-auto">
            {items.map((item) => (
              <DropdownMenuItem key={item.id}>
                {item.name}
              </DropdownMenuItem>
            ))}
          </div>
        </ScrollFadeContainer>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

Command Palette with Scroll

tsx
import { ScrollFadeContainer } from '@/shared/ui/components/scroll/ScrollFadeContainer'
import {
  Command,
  CommandInput,
  CommandList,
  CommandGroup,
  CommandItem,
} from '@/shadcn/components/ui/command'

function CommandPalette() {
  return (
    <Command>
      <CommandInput placeholder="Search..." />
      <ScrollFadeContainer fadeSize="sm">
        <CommandList className="max-h-[400px]">
          <CommandGroup heading="Suggestions">
            {/* Many items */}
          </CommandGroup>
        </CommandList>
      </ScrollFadeContainer>
    </Command>
  )
}

Table Patterns

Scrollable Table with Sticky Header

tsx
import { ScrollFadeContainer } from '@/shared/ui/components/scroll/ScrollFadeContainer'

function ScrollableTable({ data }) {
  return (
    <div className="border rounded-lg">
      <ScrollFadeContainer fades="bottom">
        <div className="max-h-[500px] overflow-y-auto">
          <table className="w-full">
            <thead className="sticky top-0 bg-background z-10 border-b">
              <tr>
                <th className="p-3 text-left">Name</th>
                <th className="p-3 text-left">Email</th>
                <th className="p-3 text-left">Role</th>
              </tr>
            </thead>
            <tbody>
              {data.map((row) => (
                <tr key={row.id} className="border-b">
                  <td className="p-3">{row.name}</td>
                  <td className="p-3">{row.email}</td>
                  <td className="p-3">{row.role}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </ScrollFadeContainer>
    </div>
  )
}

Leaderboard Table

Used in dashboards for rankings:

tsx
import { ScrollFadeContainer } from '@/shared/ui/components/scroll/ScrollFadeContainer'

function LeaderboardTable({ entries }) {
  return (
    <div className="rounded-lg border">
      <div className="p-4 border-b">
        <h3 className="font-semibold">Top Performers</h3>
      </div>
      
      <ScrollFadeContainer
        fadeSize="md"
        fadeGradient={{
          from: 'hsl(var(--card))',
        }}
      >
        <div className="max-h-[400px] overflow-y-auto">
          <table className="w-full">
            <thead className="sticky top-0 bg-card z-10">
              <tr className="border-b">
                <th className="p-3 text-left">Rank</th>
                <th className="p-3 text-left">Name</th>
                <th className="p-3 text-right">Score</th>
              </tr>
            </thead>
            <tbody>
              {entries.map((entry, index) => (
                <tr key={entry.id} className="border-b hover:bg-muted/50">
                  <td className="p-3">#{index + 1}</td>
                  <td className="p-3">{entry.name}</td>
                  <td className="p-3 text-right font-medium">
                    {entry.score}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </ScrollFadeContainer>
    </div>
  )
}

tsx
import { ScrollFadeContainer } from '@/shared/ui/components/scroll/ScrollFadeContainer'
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from '@/shadcn/components/ui/dialog'

function TermsDialog({ open, onOpenChange }) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-2xl">
        <DialogHeader>
          <DialogTitle>Terms and Conditions</DialogTitle>
        </DialogHeader>
        
        <ScrollFadeContainer
          fadeGradient={{
            from: 'hsl(var(--background))',
          }}
        >
          <div className="max-h-[60vh] overflow-y-auto prose prose-sm dark:prose-invert">
            {/* Long terms content */}
          </div>
        </ScrollFadeContainer>
      </DialogContent>
    </Dialog>
  )
}

Advanced Patterns

Custom Scroll Detection

For advanced use cases, use the hook directly:

tsx
import { useRef } from 'react'
import { useScrollFade } from '@/shared/ui/components/scroll/useScrollFade'
import { ScrollFade } from '@/shared/ui/components/scroll/ScrollFade'

function CustomScrollable() {
  const containerRef = useRef<HTMLDivElement>(null)
  const { showTop, showBottom, isScrollable } = useScrollFade(containerRef, {
    threshold: 20,
    debounce: 150,
  })

  return (
    <div ref={containerRef} className="relative">
      {isScrollable && (
        <>
          <ScrollFade position="top" visible={showTop} />
          <ScrollFade position="bottom" visible={showBottom} />
        </>
      )}
      
      <div className="h-[400px] overflow-y-auto">
        {/* Content */}
      </div>
    </div>
  )
}

Conditional Fades

Show fades only when needed:

tsx
function ConditionalFades({ items }) {
  const containerRef = useRef<HTMLDivElement>(null)
  const { showTop, showBottom, isScrollable } = useScrollFade(containerRef)

  if (!isScrollable) {
    return (
      <div className="p-4">
        {items.map((item) => (
          <div key={item.id}>{item.name}</div>
        ))}
      </div>
    )
  }

  return (
    <div ref={containerRef} className="relative">
      <ScrollFade position="top" visible={showTop} />
      
      <div className="h-[400px] overflow-y-auto p-4">
        {items.map((item) => (
          <div key={item.id}>{item.name}</div>
        ))}
      </div>
      
      <ScrollFade position="bottom" visible={showBottom} />
    </div>
  )
}

Different Background Colors

Match fades to your design:

tsx
// For card backgrounds
<ScrollFadeContainer
  fadeGradient={{
    from: 'hsl(var(--card))',
  }}
>
  <div className="bg-card">{content}</div>
</ScrollFadeContainer>

// For popover backgrounds
<ScrollFadeContainer
  fadeGradient={{
    from: 'hsl(var(--popover))',
  }}
>
  <div className="bg-popover">{content}</div>
</ScrollFadeContainer>

// For custom colors
<ScrollFadeContainer
  fadeGradient={{
    from: 'rgb(255 255 255)',
    via: 'rgb(255 255 255 / 0.8)',
  }}
>
  <div className="bg-white">{content}</div>
</ScrollFadeContainer>

Best Practices

Gradient Matching

Always match the fade gradient to the container background:

tsx
// ✅ Good - matches card background
<Card>
  <ScrollFadeContainer
    fadeGradient={{ from: 'hsl(var(--card))' }}
  >
    {content}
  </ScrollFadeContainer>
</Card>

// ❌ Bad - uses default background gradient on card
<Card>
  <ScrollFadeContainer>
    {content}
  </ScrollFadeContainer>
</Card>

Fade Size

Choose appropriate fade sizes:

tsx
// Small for compact UIs
<ScrollFadeContainer fadeSize="sm">
  <DropdownMenu>{items}</DropdownMenu>
</ScrollFadeContainer>

// Medium for standard content (default)
<ScrollFadeContainer fadeSize="md">
  <div className="h-[400px]">{content}</div>
</ScrollFadeContainer>

// Large for spacious layouts
<ScrollFadeContainer fadeSize="lg">
  <div className="h-[600px]">{content}</div>
</ScrollFadeContainer>

Performance

Optimize scroll performance:

tsx
// Increase debounce for less frequent updates
<ScrollFadeContainer
  scrollOptions={{
    debounce: 150, // Default is 50ms
  }}
>
  {content}
</ScrollFadeContainer>

// Adjust threshold to reduce toggle frequency
<ScrollFadeContainer
  scrollOptions={{
    threshold: 20, // Default is 10px
  }}
>
  {content}
</ScrollFadeContainer>

Accessibility

Fades are decorative and don't affect accessibility:

  • Automatically aria-hidden="true"
  • pointer-events: none - doesn't block interaction
  • Content remains fully accessible to screen readers