Skip to content
  1. Keyline UI
  2. Message scroller
Keyline UIComponents
  • Get started
    • Installation
    • Theming
    • Modes
    • Configuration
    • Composition
  • Actions
    • Button
    • Button group
    • Split button
    • Toggle
    • Toggle group
    • Toolbar
    • Menu
    • Menubar
    • Context menu
  • Inputs
    • Field and form
    • Checkbox
    • Checkbox group
    • Radio group
    • Switch
    • Slider
    • Segmented
    • Select
    • Native select
    • Combobox
    • Multiselect
    • Number field
    • OTP field
    • Time field
    • Date picker and calendar
    • Date range
  • Data
    • Table
    • Chart
    • Stat and meter
    • Status and badge
    • Progress
    • Skeleton
    • Empty
    • Avatar
    • Kbd
    • Marker
  • Navigation
    • Tabs
    • Breadcrumb
    • Pagination
    • Nav
    • Navigation menu
    • Command menu
  • Overlays
    • Dialog
    • Alert dialog
    • Drawer
    • Sheet
    • Popover
    • Hover card
    • Tooltip
    • Toast
    • Notice
  • Layout
    • App shell
    • Sidebar
    • Panel
    • Resizable
    • Scroll area
    • Accordion
    • Collapsible
    • Carousel
    • Inspector
    • Item
    • Direction
  • Conversation
    • Message
    • Bubble
    • Message scroller
    • Attachment
    • Questionnaire
  • Blocks
    • Shipments

Message scroller

A conversation log that stays on the newest message while you're at the end, and holds your place when you've scrolled up to read. New messages below you are counted on a jump cell, and a jump from far up takes the same short time as one from near. Your place holds when something above you changes size.

Loading the live example…

"use client"

import { useEffect, useRef, useState } from "react"
import { Avatar } from "@/components/keyline/ui/avatar"
import { Bubble, BubbleContent } from "@/components/keyline/ui/bubble"
import { Button } from "@/components/keyline/ui/button"
import { Field, FieldFrame, FieldInput, FieldLabel } from "@/components/keyline/ui/field"
import { Message, MessageAvatar, MessageContent, MessageHeader } from "@/components/keyline/ui/message"
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerSeparator,
  MessageScrollerViewport,
  useMessageScroller,
} from "@/components/keyline/ui/message-scroller"

type Msg = { id: string; own: boolean; at: string; text: string; day?: string }

const SEED: Msg[] = [
  { id: "m1", own: false, at: "8:55", text: "Truck's loaded. Lackawanna first, then Blue Cross.", day: "Today" },
  { id: "m2", own: true, at: "8:57", text: "Vance Refrigeration is on the list too. 12 cases." },
  { id: "m3", own: false, at: "8:58", text: "Last time nobody was there to sign." },
  { id: "m4", own: true, at: "9:00", text: "Bob says someone will be on the dock from nine." },
  { id: "m5", own: false, at: "9:02", text: "Then I'll go there after lunch." },
  { id: "m6", own: false, at: "9:02", text: "Cugino's, if anyone wants anything." },
]

const REPLIES = ["Lackawanna's done. 120 cases signed for.", "Blue Cross wants to know about the recycled stock.", "Nobody at Vance. Again.", "Heading back. Traffic on 81."]

// consecutive messages from the same person, close in time, make one run (one Message)
const runs = (ms: Msg[]) => ms.reduce<Msg[][]>((acc, m) => (acc.at(-1)?.[0].own === m.own && !m.day ? (acc.at(-1)!.push(m), acc) : [...acc, [m]]), [])

function Chat() {
  const { scrollToEnd } = useMessageScroller()
  const [msgs, setMsgs] = useState(SEED)
  const [draft, setDraft] = useState("")
  const sent = useRef(false)
  const n = useRef(0)

  // after the reader sends, the log goes to the end once the new message is in
  useEffect(() => {
    if (sent.current) {
      sent.current = false
      scrollToEnd()
    }
  }, [msgs, scrollToEnd])

  const add = (own: boolean, text: string) => {
    const i = n.current++
    setMsgs((ms) => [...ms, { id: `n${i}`, own, at: `9:${String(10 + i).padStart(2, "0")}`, text }])
  }

  return (
    <div className="grid gap-3">
      <MessageScroller className="h-[380px]">
        <MessageScrollerViewport aria-label="Conversation with Darryl Philbin">
          <MessageScrollerContent>
            {runs(msgs).map((run) => (
              <MessageScrollerItem key={run[0].id} messageId={run[0].id}>
                {run[0].day ? <MessageScrollerSeparator>{run[0].day}</MessageScrollerSeparator> : null}
                <Message align={run[0].own ? "end" : "start"}>
                  {run[0].own ? null : (
                    <MessageAvatar>
                      <Avatar name="Darryl Philbin" decorative />
                    </MessageAvatar>
                  )}
                  <MessageContent>
                    <MessageHeader>
                      <span>{run[0].own ? "You" : "Darryl Philbin"}</span>
                      <time>{run[0].at}</time>
                    </MessageHeader>
                    {run.map((m) => (
                      <Bubble key={m.id} tone={m.own ? "tint" : "plain"}>
                        <BubbleContent>{m.text}</BubbleContent>
                      </Bubble>
                    ))}
                  </MessageContent>
                </Message>
              </MessageScrollerItem>
            ))}
          </MessageScrollerContent>
        </MessageScrollerViewport>
        <MessageScrollerButton />
      </MessageScroller>
      <form
        className="flex items-end gap-3"
        onSubmit={(e) => {
          e.preventDefault()
          if (!draft.trim()) return
          sent.current = true
          add(true, draft.trim())
          setDraft("")
        }}
      >
        <Field className="flex-1">
          <FieldLabel className="sr-only">Message</FieldLabel>
          <FieldFrame>
            <FieldInput value={draft} onValueChange={(v) => setDraft(String(v))} placeholder="Write to Darryl" />
          </FieldFrame>
        </Field>
        <Button type="submit" variant="primary">
          Send
        </Button>
      </form>
      <Button size="compact" variant="quiet" className="justify-self-start" onClick={() => add(false, REPLIES[n.current % REPLIES.length])}>
        Darryl replies
      </Button>
    </div>
  )
}

export function DriverChat() {
  return (
    <MessageScrollerProvider>
      <Chat />
    </MessageScrollerProvider>
  )
}

Installation

npx shadcn@latest add @keyline-ui/message-scroller

Run it where your components.json is. The first time, map the @keyline-ui registry: one command, in Installation.

Usage

import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
} from "@/components/keyline/ui/message-scroller"
<MessageScrollerProvider>
  <MessageScroller className="h-[380px]">
    <MessageScrollerViewport aria-label="Conversation with Darryl Philbin">
      <MessageScrollerContent>
        <MessageScrollerItem messageId="m1"><Message>…</Message></MessageScrollerItem>
      </MessageScrollerContent>
    </MessageScrollerViewport>
    <MessageScrollerButton />
  </MessageScroller>
</MessageScrollerProvider>

Props

PropTypeDefaultDescription
autoScrollbooleanDefault trueOn MessageScrollerProvider: follow new messages while the reader is at the end.
aria-labelstringDefault "Messages"On MessageScrollerViewport: who the conversation is with.
messageIdstringDefault —On MessageScrollerItem: to scroll to a message, or to land on one.
MessageScrollerSeparatordivDefault —A day or &ldquo;New messages&rdquo; divider: a seam with its words in the code voice.
direction"end" | "start"Default "end"On MessageScrollerButton: the jump cell's target.
useMessageScroller() => { scrollToEnd, … }Default —After the reader sends, call <code>scrollToEnd()</code>; their own message always shows.

Scroll up in the example, then press Darryl replies: the jump cell counts what arrived. Built on @shadcn/react's MessageScroller, scrolling in Keyline's Scroll Area.

Sheet 70 of 73PreviousBubbleNextAttachment
On this page