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-scrollerpnpm dlx kosmesis add message-scrolleryarn dlx kosmesis add message-scrollerbunx kosmesis add message-scrollerThis 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.
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/csspnpm add @praxisjs/cssyarn add @praxisjs/cssbun add @praxisjs/cssThis 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.
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.