Sabil UI

Search documentation

Search for a command to run...

Components

Message Scroller

A scroll container for chat that keeps the newest message in view.

Can you summarize the launch checklist?
Sure. Three things are left: final QA, the pricing page and the announcement email.
Who owns the pricing page?
Maya owns it. The draft is ready for review since yesterday.
Great. Remind me tomorrow morning.
Reminder set for 9:00 tomorrow.

Installation#

  1. Install the dependencies

    npm install @shadcn/react cn lucide-react class-variance-authority radix-ui
  2. Copy the source into your project

    components/ui/message-scroller.tsx
    "use client"import * as React from "react"import {  MessageScroller as MessageScrollerPrimitive,  useMessageScroller,  useMessageScrollerScrollable,  useMessageScrollerVisibility,} from "@shadcn/react/message-scroller"import { cn } from "cn"import { Button } from "@/components/ui/button"import { ArrowDownIcon } from "lucide-react"function MessageScrollerProvider(  props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>) {  return <MessageScrollerPrimitive.Provider {...props} />}function MessageScroller({  className,  ...props}: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {  return (    <MessageScrollerPrimitive.Root      data-slot="message-scroller"      className={cn(        "group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",        className      )}      {...props}    />  )}function MessageScrollerViewport({  className,  ...props}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {  return (    <MessageScrollerPrimitive.Viewport      data-slot="message-scroller-viewport"      className={cn(        "size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent data-pending-scroll:invisible",        className      )}      {...props}    />  )}function MessageScrollerContent({  className,  ...props}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {  return (    <MessageScrollerPrimitive.Content      data-slot="message-scroller-content"      className={cn("flex h-max min-h-full flex-col gap-6", className)}      {...props}    />  )}function MessageScrollerItem({  className,  scrollAnchor = false,  ...props}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {  return (    <MessageScrollerPrimitive.Item      data-slot="message-scroller-item"      scrollAnchor={scrollAnchor}      className={cn(        "min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",        className      )}      {...props}    />  )}function MessageScrollerButton({  direction = "end",  className,  children,  render,  variant = "secondary",  size = "icon-sm",  ...props}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &  Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {  return (    <MessageScrollerPrimitive.Button      data-slot="message-scroller-button"      data-direction={direction}      data-variant={variant}      data-size={size}      direction={direction}      className={cn(        "absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",        className      )}      render={render ?? <Button variant={variant} size={size} />}      {...props}    >      {children ?? (        <>          <ArrowDownIcon          />          <span className="sr-only">            {direction === "end" ? "Scroll to end" : "Scroll to start"}          </span>        </>      )}    </MessageScrollerPrimitive.Button>  )}export {  MessageScrollerProvider,  MessageScroller,  MessageScrollerViewport,  MessageScrollerContent,  MessageScrollerItem,  MessageScrollerButton,  useMessageScroller,  useMessageScrollerScrollable,  useMessageScrollerVisibility,}
    components/ui/message.tsx
    import * as React from "react"import { cn } from "cn"function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {  return (    <div      data-slot="message-group"      className={cn("flex min-w-0 flex-col gap-2", className)}      {...props}    />  )}function Message({  className,  align = "start",  ...props}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {  return (    <div      data-slot="message"      data-align={align}      className={cn(        "group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",        className      )}      {...props}    />  )}function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {  return (    <div      data-slot="message-avatar"      className={cn(        "flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",        className      )}      {...props}    />  )}function MessageContent({ className, ...props }: React.ComponentProps<"div">) {  return (    <div      data-slot="message-content"      className={cn(        "flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",        className      )}      {...props}    />  )}function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {  return (    <div      data-slot="message-header"      className={cn(        "flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",        className      )}      {...props}    />  )}function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {  return (    <div      data-slot="message-footer"      className={cn(        "flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",        className      )}      {...props}    />  )}export {  MessageGroup,  Message,  MessageAvatar,  MessageContent,  MessageFooter,  MessageHeader,}
    components/ui/bubble.tsx
    import * as React from "react"import { cva, type VariantProps } from "class-variance-authority"import { cn } from "cn"import { Slot } from "radix-ui"function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {  return (    <div      data-slot="bubble-group"      className={cn("flex min-w-0 flex-col gap-2", className)}      {...props}    />  )}const bubbleVariants = cva(  "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",  {    variants: {      variant: {        default:          "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",        secondary:          "*:data-[slot=bubble-content]:bg-bubble-strong *:data-[slot=bubble-content]:text-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklab,var(--bubble-strong),var(--foreground)_6%)]",        muted:          "*:data-[slot=bubble-content]:bg-bubble *:data-[slot=bubble-content]:text-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-bubble-strong",        tinted:          "*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",        outline:          "*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",        ghost:          "border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",        destructive:          "*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",      },    },    defaultVariants: {      variant: "default",    },  })function Bubble({  variant = "default",  align = "start",  className,  ...props}: React.ComponentProps<"div"> &  VariantProps<typeof bubbleVariants> & {    align?: "start" | "end"  }) {  return (    <div      data-slot="bubble"      data-variant={variant}      data-align={align}      className={cn(bubbleVariants({ variant }), className)}      {...props}    />  )}function BubbleContent({  asChild = false,  className,  ...props}: React.ComponentProps<"div"> & {  asChild?: boolean}) {  const Comp = asChild ? Slot.Root : "div"  return (    <Comp      data-slot="bubble-content"      className={cn(        "w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",        className      )}      {...props}    />  )}const bubbleReactionsVariants = cva(  "absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",  {    variants: {      side: {        top: "top-0 -translate-y-3/4",        bottom: "bottom-0 translate-y-3/4",      },      align: {        start: "left-3",        end: "right-3",      },    },    defaultVariants: {      side: "bottom",      align: "end",    },  })function BubbleReactions({  side = "bottom",  align = "end",  className,  ...props}: React.ComponentProps<"div"> & {  align?: "start" | "end"  side?: "top" | "bottom"}) {  return (    <div      data-slot="bubble-reactions"      data-align={align}      data-side={side}      className={cn(bubbleReactionsVariants({ side, align }), className)}      {...props}    />  )}export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }
    
  3. Make sure the foundation is in place

    Components read their colors, radius and surfaces from the tokens in app/globals.css. See Theming.

Usage#

import {  MessageScrollerProvider,  MessageScroller,  MessageScrollerViewport,  MessageScrollerContent,  MessageScrollerItem,  MessageScrollerButton,} from "@/components/ui/message-scroller"