Component
Scroll Area
A scrollable area component with customizable top and bottom shadows that appear when content overflows.
New comment
Sarah left a comment on your post
New follower
Alex started following you
Payment received
You received $250.00 from Client Co.
Reminder
Team standup meeting in 30 minutes
New message
Jordan sent you a direct message
Event tomorrow
Product launch scheduled for 9:00 AM
New comment
Sarah left a comment on your post
New follower
Alex started following you
'use client'
import {
ScrollAreaViewport,
ScrollAreaContent,
} from '@/components/scroll-area'
import { useState, useRef, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import {
Plus,
X,
MessageSquare,
UserPlus,
CreditCard,
Bell,
Mail,
Calendar,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const notifications = [
{
icon: MessageSquare,
title: 'New comment',
description: 'Sarah left a comment on your post',
},
{
icon: UserPlus,
title: 'New follower',
description: 'Alex started following you',
},
{
icon: CreditCard,
title: 'Payment received',
description: 'You received $250.00 from Client Co.',
},
{
icon: Bell,
title: 'Reminder',
description: 'Team standup meeting in 30 minutes',
},
{
icon: Mail,
title: 'New message',
description: 'Jordan sent you a direct message',
},
{
icon: Calendar,
title: 'Event tomorrow',
description: 'Product launch scheduled for 9:00 AM',
},
]
function ScrollAreaDemo() {
const [items, setItems] = useState<number[]>([0, 1, 2, 3, 4, 5, 6, 7])
const nextIdRef = useRef(4)
const scrollRef = useRef<HTMLDivElement>(null)
const prevLengthRef = useRef(items.length)
useEffect(() => {
// Only scroll when items are added (length increases)
if (items.length > prevLengthRef.current && scrollRef.current) {
scrollRef.current.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth',
})
}
prevLengthRef.current = items.length
}, [items.length])
const addNotification = () => {
setItems((prev) => [...prev, nextIdRef.current++])
}
const dismissNotification = (id: number) => {
setItems((prev) => prev.filter((item) => item !== id))
}
return (
<div className="mx-auto w-full max-w-md p-10">
<ScrollAreaViewport
className="h-100 w-full"
topShadowGradient="bg-linear-to-b from-card to-transparent"
bottomShadowGradient="bg-linear-to-t from-card to-transparent"
>
<ScrollAreaContent ref={scrollRef} className="space-y-2">
{items.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center text-center">
<div className="bg-muted mb-3 rounded-full p-3">
<Bell className="text-muted-foreground h-6 w-6" />
</div>
<p className="text-muted-foreground text-sm">No notifications</p>
</div>
) : (
items.map((id, index) => {
const notification = notifications[id % notifications.length]
const Icon = notification.icon
return (
<div
key={`${id}-${index}`}
className="bg-background rounded-lg border p-3"
>
<div className="flex items-start gap-3">
<div className={cn('bg-muted rounded-sm p-2')}>
<Icon className={cn('text-muted-foreground h-5 w-5')} />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-medium">{notification.title}</h3>
<p className="text-muted-foreground mt-0.5 text-xs">
{notification.description}
</p>
</div>
<button
onClick={() => dismissNotification(id)}
className="text-muted-foreground hover:text-foreground -mt-1 -mr-1 rounded p-1 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
)
})
)}
</ScrollAreaContent>
</ScrollAreaViewport>
<div className="mt-4 w-full">
<Button className="w-full" onClick={addNotification}>
<Plus className="h-4 w-4" />
Trigger Notification
</Button>
</div>
</div>
)
}
export default ScrollAreaDemo
Installation
pnpm dlx shadcn@latest add https://r.joyco.studio/scroll-area.json
With Chevron Demo
New comment
Sarah left a comment on your post
New follower
Alex started following you
Payment received
You received $250.00 from Client Co.
Reminder
Team standup meeting in 30 minutes
New message
Jordan sent you a direct message
Event tomorrow
Product launch scheduled for 9:00 AM
New comment
Sarah left a comment on your post
New follower
Alex started following you
'use client'
import { useEffect, useRef, useState } from 'react'
import {
Bell,
Calendar,
ChevronDown,
CreditCard,
ChevronUp,
Mail,
Plus,
MessageSquare,
UserPlus,
X,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import {
ScrollAreaViewport,
ScrollAreaContent,
} from '@/components/scroll-area'
const notifications = [
{
icon: MessageSquare,
title: 'New comment',
description: 'Sarah left a comment on your post',
},
{
icon: UserPlus,
title: 'New follower',
description: 'Alex started following you',
},
{
icon: CreditCard,
title: 'Payment received',
description: 'You received $250.00 from Client Co.',
},
{
icon: Bell,
title: 'Reminder',
description: 'Team standup meeting in 30 minutes',
},
{
icon: Mail,
title: 'New message',
description: 'Jordan sent you a direct message',
},
{
icon: Calendar,
title: 'Event tomorrow',
description: 'Product launch scheduled for 9:00 AM',
},
]
function ChevronExample() {
const [items, setItems] = useState<number[]>([0, 1, 2, 3, 4, 5, 6, 7])
const nextIdRef = useRef(4)
const scrollRef = useRef<HTMLDivElement>(null)
const prevLengthRef = useRef(items.length)
useEffect(() => {
// Only scroll when items are added (length increases)
if (items.length > prevLengthRef.current && scrollRef.current) {
scrollRef.current.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth',
})
}
prevLengthRef.current = items.length
}, [items.length])
const addNotification = () => {
setItems((prev) => [...prev, nextIdRef.current++])
}
const dismissNotification = (id: number) => {
setItems((prev) => prev.filter((item) => item !== id))
}
return (
<div className="mx-auto w-full max-w-md p-10">
<ScrollAreaViewport
className="h-[400px] w-full"
topShadowGradient="bg-linear-to-b from-card to-transparent"
bottomShadowGradient="bg-linear-to-t from-card to-transparent"
>
{/* Scroll indicator arrows */}
<div
className={cn(
'bg-background border-border pointer-events-none absolute top-2 left-1/2 z-30 -translate-x-1/2 rounded-full border p-0.5 transition-opacity duration-300',
'group-data-[scroll-top=true]/scroll-area:opacity-100',
'opacity-0'
)}
>
<ChevronUp className="text-muted-foreground h-5 w-5" />
</div>
<div
className={cn(
'bg-background border-border pointer-events-none absolute bottom-2 left-1/2 z-30 -translate-x-1/2 rounded-full border p-0.5 transition-opacity duration-300',
'group-data-[scroll-bottom=true]/scroll-area:opacity-100',
'opacity-0'
)}
>
<ChevronDown className="text-muted-foreground h-5 w-5" />
</div>
<ScrollAreaContent ref={scrollRef} className="space-y-2">
{items.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center text-center">
<div className="bg-muted mb-3 rounded-full p-3">
<Bell className="text-muted-foreground h-6 w-6" />
</div>
<p className="text-muted-foreground text-sm">No notifications</p>
</div>
) : (
items.map((id, index) => {
const notification = notifications[id % notifications.length]
const Icon = notification.icon
return (
<div
key={`${id}-${index}`}
className="bg-background rounded-lg border p-3"
>
<div className="flex items-start gap-3">
<div className={cn('bg-muted rounded-sm p-2')}>
<Icon className={cn('text-muted-foreground h-5 w-5')} />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-medium">{notification.title}</h3>
<p className="text-muted-foreground mt-0.5 text-xs">
{notification.description}
</p>
</div>
<button
onClick={() => dismissNotification(id)}
className="text-muted-foreground hover:text-foreground -mt-1 -mr-1 rounded p-1 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
)
})
)}
</ScrollAreaContent>
</ScrollAreaViewport>
<div className="mt-4 w-full">
<Button className="w-full" onClick={addNotification}>
<Plus className="h-4 w-4" />
Trigger Notification
</Button>
</div>
</div>
)
}
export default ChevronExample
Usage
import { ScrollAreaViewport, ScrollAreaContent } from '@/components/scroll-area'
<ScrollAreaViewport>
<ScrollAreaContent>
<div>Content here</div>
</ScrollAreaContent>
</ScrollAreaViewport>