Layout
Scroll Area
Scrollable viewport with a slim, cyan-tinted scrollbar, styled with the quebi design system. Supports vertical, horizontal, or both axes, optional edge fading, and a reserved scrollbar gutter.
layoutscrolloverflowcontainerviewport
Vertical
A fixed-height container that scrolls vertically with a slim cyan scrollbar.
Horizontal
Set orientation to horizontal to scroll a row of items sideways.
Edge fade
Enable scrollFade to softly mask content at the scrolled edges.
Scrollbar gutter
scrollbarGutter reserves space so content doesn't shift when the bar appears.
Source
Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import { useLayoutEffect, useRef } from "react"
import { cn } from "@/lib/utils"
type ScrollAreaOrientation = "vertical" | "horizontal" | "both"
export interface ScrollAreaProps extends React.ComponentPropsWithRef<"div"> {
scrollFade?: boolean
scrollbarGutter?: boolean
orientation?: ScrollAreaOrientation
}
/**
* ScrollArea — quebi design system
*
* A scrollable viewport with a slim, cyan-tinted scrollbar (thumb cyan-500/20).
* Optionally fades content at the scrolled edges (`scrollFade`) and reserves a
* gutter so content doesn't shift when the scrollbar appears (`scrollbarGutter`).
* Self-contained: no Radix, no portals — just a native overflow container with
* overflow-state data attributes wired up for masking.
*/
export function ScrollArea({
ref: forwardedRef,
className,
children,
scrollFade = false,
scrollbarGutter = false,
orientation = "both",
...props
}: ScrollAreaProps) {
const viewportRef = useRef<HTMLDivElement>(null)
const rafRef = useRef<number | null>(null)
const isThrottledRef = useRef(false)
const allowY = orientation === "vertical" || orientation === "both"
const allowX = orientation === "horizontal" || orientation === "both"
useLayoutEffect(() => {
const el = viewportRef.current
if (!el) return
const update = () => {
const rawHasY = el.scrollHeight > el.clientHeight + 1
const rawHasX = el.scrollWidth > el.clientWidth + 1
const hasY = allowY && rawHasY
const hasX = allowX && rawHasX
el.toggleAttribute("data-has-overflow-y", hasY)
el.toggleAttribute("data-has-overflow-x", hasX)
const yStart = hasY ? Math.max(0, el.scrollTop) : 0
const yEnd = hasY ? Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop) : 0
const xStart = hasX ? Math.max(0, el.scrollLeft) : 0
const xEnd = hasX ? Math.max(0, el.scrollWidth - el.clientWidth - el.scrollLeft) : 0
el.style.setProperty("--scroll-area-overflow-y-start", `${yStart}px`)
el.style.setProperty("--scroll-area-overflow-y-end", `${yEnd}px`)
el.style.setProperty("--scroll-area-overflow-x-start", `${xStart}px`)
el.style.setProperty("--scroll-area-overflow-x-end", `${xEnd}px`)
}
const scheduleUpdate = () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
rafRef.current = requestAnimationFrame(update)
}
const throttledScrollUpdate = () => {
if (isThrottledRef.current) return
isThrottledRef.current = true
scheduleUpdate()
setTimeout(() => {
isThrottledRef.current = false
}, 16)
}
const ro = new ResizeObserver(scheduleUpdate)
ro.observe(el)
el.addEventListener("scroll", throttledScrollUpdate, { passive: true })
update()
return () => {
el.removeEventListener("scroll", throttledScrollUpdate)
ro.disconnect()
if (rafRef.current) cancelAnimationFrame(rafRef.current)
}
}, [allowX, allowY])
return (
<div ref={forwardedRef} className={cn("size-full min-h-0", className)} {...props}>
<div
ref={viewportRef}
className={cn(
"h-full overscroll-auto rounded-[inherit] outline-none transition-shadow",
"data-has-overflow-y:overscroll-y-contain data-has-overflow-x:overscroll-x-contain",
// Slim, cyan-tinted scrollbar — quebi tokens.
"[scrollbar-width:thin] [scrollbar-color:color-mix(in_oklab,var(--color-cyan-500)_20%,transparent)_transparent]",
"[&::-webkit-scrollbar]:size-2",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:rounded-quebi-sm [&::-webkit-scrollbar-thumb]:bg-cyan-500/20",
"[&::-webkit-scrollbar-thumb:hover]:bg-cyan-500/30",
orientation === "vertical"
? "overflow-x-hidden overflow-y-auto"
: orientation === "horizontal"
? "overflow-x-auto overflow-y-hidden"
: "overflow-auto",
scrollFade && [
allowY &&
"mask-t-from-[calc(100%-min(var(--fade-size,--spacing(6)),var(--scroll-area-overflow-y-start,0)))] mask-b-from-[calc(100%-min(var(--fade-size,--spacing(6)),var(--scroll-area-overflow-y-end,0)))]",
allowX &&
"mask-l-from-[calc(100%-min(var(--fade-size,--spacing(6)),var(--scroll-area-overflow-x-start,0)))] mask-r-from-[calc(100%-min(var(--fade-size,--spacing(6)),var(--scroll-area-overflow-x-end,0)))]",
],
scrollbarGutter && [
allowY && "data-has-overflow-y:pe-2.5",
allowX && "data-has-overflow-x:pb-2.5",
],
)}
data-slot="scroll-area-viewport"
>
{children}
</div>
</div>
)
}