Skip to content

Instantly share code, notes, and snippets.

@szmazhr
Last active September 24, 2025 20:08
Show Gist options
  • Select an option

  • Save szmazhr/cac4c27121e79eaaf85e45e5fd7c6094 to your computer and use it in GitHub Desktop.

Select an option

Save szmazhr/cac4c27121e79eaaf85e45e5fd7c6094 to your computer and use it in GitHub Desktop.
Extends the standard shadcn/ui ScrollArea component to support automatic stick-to-bottom behaviour for chat interfaces while maintaining theme consistency.
/**
* Enhanced shadcn ScrollArea with Stick-to-Bottom Functionality
*
* Extends the standard shadcn/ui ScrollArea component to support automatic
* stick-to-bottom behavior for chat interfaces while maintaining theme consistency.
*
* Features:
* - Two modes: default scrolling and stick-to-bottom
* - Context-based scroll state management
* - Smart scroll button that appears when not at bottom
* - Preserves shadcn styling and theme integration
*
* Dependencies:
* - @radix-ui/react-scroll-area
* - use-stick-to-bottom
* - lucide-react
* - class-variance-authority
* - shadcn/ui Button component
*
* Usage:
* ```tsx
* // Standard mode
* <ScrollArea className="h-96">
* <div>Content</div>
* </ScrollArea>
*
* // Chat mode with stick-to-bottom
* <ScrollArea mode="stick-to-bottom" className="h-96">
* <div>{messages.map(msg => <Message key={msg.id} {...msg} />)}</div>
* <ScrollButton />
* </ScrollArea>
* ```
*
* @author szmazhr (https://github.com/szmazhr)
* @version 1.0.0
*/
"use client";
import {
ComponentProps,
createContext,
useCallback,
useContext,
useMemo,
useRef,
} from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { ArrowDownIcon } from "lucide-react";
import { cva, VariantProps } from "class-variance-authority";
import { useStickToBottom } from "use-stick-to-bottom";
import { cn } from "@/shared/utils";
import { Button } from "@/components/ui/button";
// Context for sharing scroll state between components
interface ScrollAreaContextType {
isAtBottom: boolean;
isNearBottom: boolean;
scrollToBottom: () => void;
}
const ScrollAreaContext = createContext<ScrollAreaContextType | null>(null);
// Hook to access scroll area context with error handling
export const useScrollArea = () => {
const context = useContext(ScrollAreaContext);
if (!context) {
throw new Error(
"useScrollArea must be used within a <ScrollArea mode='stick-to-bottom'>",
);
}
return context;
};
// Custom scrollbar component with theme-aware styling
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
// Internal component implementing stick-to-bottom behavior
function ScrollAreaBottomStick({
className,
children,
...props
}: ComponentProps<typeof ScrollAreaPrimitive.Root>) {
// Get scroll management from external library
const {
scrollRef: libScrollRef,
contentRef: libContentRef,
isAtBottom,
isNearBottom,
scrollToBottom,
} = useStickToBottom();
// Local ref for potential external access
const localContainerRef = useRef<HTMLDivElement | null>(null);
// Merge library ref with local ref to satisfy both dependencies
const mergedScrollRef = useCallback(
(node: HTMLDivElement | null) => {
localContainerRef.current = node;
if (!libScrollRef) return;
if (typeof libScrollRef === "function") libScrollRef(node);
else
(libScrollRef as React.RefObject<HTMLDivElement | null>).current = node;
},
[libScrollRef],
);
// Content ref must be attached to direct child of Viewport for proper measurement
const mergedContentRef = useCallback(
(node: HTMLDivElement | null) => {
if (!libContentRef) return;
if (typeof libContentRef === "function") libContentRef(node);
else
(libContentRef as React.RefObject<HTMLDivElement | null>).current =
node;
},
[libContentRef],
);
// Memoize context values to prevent unnecessary re-renders
const values = useMemo(() => {
return {
isNearBottom,
isAtBottom,
scrollToBottom,
};
}, [isAtBottom, scrollToBottom, isNearBottom]);
return (
<ScrollAreaContext.Provider value={values}>
<ScrollAreaPrimitive.Root
className={cn("relative h-full overflow-auto", className)}
{...props}
>
{/* Viewport is the scrolling element - needs merged ref for tracking */}
<ScrollAreaPrimitive.Viewport
ref={mergedScrollRef}
className="h-full w-full"
>
{/* Content wrapper needs ref for content measurement */}
<div ref={mergedContentRef} className="min-h-full">
{children}
</div>
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
</ScrollAreaContext.Provider>
);
}
// Main component with mode switching
interface ScrollAreaProps
extends ComponentProps<typeof ScrollAreaPrimitive.Root> {
mode?: "default" | "stick-to-bottom";
}
function ScrollArea({
className,
children,
mode = "default",
...props
}: ScrollAreaProps) {
// Switch between default and enhanced behavior
if (mode === "stick-to-bottom") {
return (
<ScrollAreaBottomStick className={className} {...props}>
{children}
</ScrollAreaBottomStick>
);
}
// Standard shadcn ScrollArea behavior
return (
<ScrollAreaPrimitive.Root
className={cn("relative h-full overflow-auto", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full">
<div className="min-h-full">{children}</div>
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
// Styled variants for scroll button positioning
const scrollButtonVariants = cva(
"absolute left-[50%] translate-x-[-50%] rounded-full",
{
variants: {
direction: {
default: "bottom-4",
bottom: "bottom-4",
},
},
defaultVariants: {
direction: "default",
},
},
);
// Floating button that appears when not at bottom
function ScrollButton({
className,
direction = "default",
...props
}: ComponentProps<"button"> & VariantProps<typeof scrollButtonVariants>) {
const { isAtBottom, scrollToBottom } = useScrollArea();
const handleScroll = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
// Only render when not at bottom
return (
!isAtBottom && (
<Button
className={cn(scrollButtonVariants({ direction, className }))}
onClick={handleScroll}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
}
export { ScrollArea, ScrollBar, ScrollButton };
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment