Kosmesis
Components

Message Scroller

A scrollable chat viewport with an imperative scrollToBottom() that auto-scrolls on mount and surfaces a ScrollButton when scrolled away from the bottom. Pairs with Message.

Installation

npx kosmesis add message-scroller
pnpm dlx kosmesis add message-scroller
yarn dlx kosmesis add message-scroller
bunx kosmesis add message-scroller

This component builds on other Kosmesis components — install these first (via kosmesis add or this same manual process on their own pages): scroll-button.

Copy and paste the following code into your project.

message-scroller.tsx
import { StatefulComponent } from "@praxisjs/core";import { Component, Prop, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { ScrollButton } from "./scroll-button";import { cn } from "@/lib/utils";export interface MessageScrollerProps {  class?: string;  children?: Children;}// PraxisJS has no "children changed" hook — call `.scrollToBottom()` explicitly after appending// a new message.@Component()export class MessageScroller extends StatefulComponent {  @Prop() class?: string;  @Prop() children?: MessageScrollerProps["children"];  @Ref<HTMLDivElement>()  viewportRef!: RefType<HTMLDivElement>;  @State() _atBottom = true;  private readonly _handleScroll = () => {    const el = this.viewportRef.current;    if (!el) return;    this._atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24;  };  scrollToBottom(behavior: ScrollBehavior = "auto"): void {    const el = this.viewportRef.current;    if (!el) return;    el.scrollTo({ top: el.scrollHeight, behavior });  }  onMount() {    this.scrollToBottom();  }  render() {    return (      <div data-slot="message-scroller" class={cn("relative flex h-full flex-col", this.class)}>        <div          ref={this.viewportRef}          data-slot="message-scroller-viewport"          class="flex-1 overflow-y-auto overscroll-contain [scrollbar-width:thin]"          onScroll={this._handleScroll}        >          {this.children}        </div>        {() => (!this._atBottom ? <ScrollButton onClick={() => { this.scrollToBottom("smooth"); }} /> : null)}      </div>    );  }}

Install the following dependencies:

npm install @praxisjs/css
pnpm add @praxisjs/css
yarn add @praxisjs/css
bun add @praxisjs/css

This component builds on other Kosmesis components — install these first (via kosmesis add or this same manual process on their own pages): scroll-button.

Copy and paste the following code into your project.

message-scroller.tsx
import { StatefulComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled } from "@praxisjs/css";import { Component, Prop, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { ScrollButton } from "./scroll-button";class MessageScrollerStyles extends Stylesheet {  $root = this.css({ position: "relative", display: "flex", height: "100%", flexDirection: "column" });  $viewport = this.css({    flex: "1 1 0%",    overflowY: "auto",    overscrollBehavior: "contain",    scrollbarWidth: "thin",  });}export interface MessageScrollerProps {  class?: string;  children?: Children;}// PraxisJS has no "children changed" hook — call `.scrollToBottom()` explicitly after appending// a new message.@Component()export class MessageScroller extends StatefulComponent {  @Prop() class?: string;  @Prop() children?: MessageScrollerProps["children"];  @Styled(MessageScrollerStyles) $s!: MessageScrollerStyles;  @Ref<HTMLDivElement>()  viewportRef!: RefType<HTMLDivElement>;  @State() _atBottom = true;  private readonly _handleScroll = () => {    const el = this.viewportRef.current;    if (!el) return;    this._atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24;  };  scrollToBottom(behavior: ScrollBehavior = "auto"): void {    const el = this.viewportRef.current;    if (!el) return;    el.scrollTo({ top: el.scrollHeight, behavior });  }  onMount() {    this.scrollToBottom();  }  render() {    return (      <div data-slot="message-scroller" class={cx(this.$s.$root, this.class)}>        <div ref={this.viewportRef} data-slot="message-scroller-viewport" class={this.$s.$viewport} onScroll={this._handleScroll}>          {this.children}        </div>        {() => (!this._atBottom ? <ScrollButton onClick={() => { this.scrollToBottom("smooth"); }} /> : null)}      </div>    );  }}

Examples

Usage

ref on a JSX tag only ever binds a DOM node, never a component instance — scrollToBottom() is reached by refing a wrapping element and querying its [data-slot="message-scroller-viewport"] descendant, or (simpler) by keeping MessageScroller itself out of state and calling scrollIntoView() on a sentinel element after the last message instead:

import { Ref } from "@praxisjs/decorators";
import type { Ref as RefType } from "@praxisjs/decorators";
import { Message, MessageGroup } from "@/components/ui/message";
import { MessageScroller } from "@/components/ui/message-scroller";

@Component()
class Chat extends StatefulComponent {
  @State() messages: { from: "user" | "assistant"; text: string }[] = [];

  @Ref<HTMLDivElement>()
  wrapperRef!: RefType<HTMLDivElement>;

  addMessage(from: "user" | "assistant", text: string) {
    this.messages = [...this.messages, { from, text }];
    queueMicrotask(() => {
      const viewport = this.wrapperRef.current?.querySelector<HTMLDivElement>('[data-slot="message-scroller-viewport"]');
      viewport?.scrollTo({ top: viewport.scrollHeight, behavior: "smooth" });
    });
  }

  render() {
    return (
      <div ref={this.wrapperRef} class="h-96">
        <MessageScroller>
          <MessageGroup>
            {() => this.messages.map((m) => <Message from={m.from}>{m.text}</Message>)}
          </MessageGroup>
        </MessageScroller>
      </div>
    );
  }
}

PraxisJS has no DOM-mutation-observer-style "children changed" hook built in, so scrollToBottom() is called explicitly after appending a message rather than automatically.

Props

MessageScroller takes no props beyond class/children — it auto-scrolls to the bottom on mount and surfaces a ScrollButton whenever scrolled more than 24px away from the bottom (wired to call scrollToBottom("smooth")). Its own scrollToBottom(behavior?) method isn't reachable from outside (ref only ever binds a DOM node, never a component instance) — see the Usage example above for calling it from a parent via the [data-slot="message-scroller-viewport"] descendant.

On this page