Kosmesis
Components

Sticky Scroll

A two-column scroll-linked reveal with a sticky synced panel. Purely presentational — no Morphos equivalent.

Installation

npx kosmesis add sticky-scroll
pnpm dlx kosmesis add sticky-scroll
yarn dlx kosmesis add sticky-scroll
bunx kosmesis add sticky-scroll

Copy and paste the following code into your project.

sticky-scroll.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 { cn } from "@/lib/utils";export interface StickyScrollItem {  id: string;  title: string;  description: string;  content?: Children;}export interface StickyScrollProps {  items: StickyScrollItem[];  class?: string;  id?: string;}// `IntersectionObserver`'s `rootMargin` percentages resolve against `root`'s own box, not the// document — leaving `root` unset (page viewport) makes the "-40% ... -40%" band drift away from// the actual visible area whenever `StickyScroll` sits inside a nested scroll container (any// scrollable ancestor before `<body>`), and that drift changes with the viewport size.function findScrollParent(el: HTMLElement): HTMLElement | null {  let node = el.parentElement;  while (node && node !== document.body) {    const { overflowY } = getComputedStyle(node);    if (overflowY === "auto" || overflowY === "scroll") return node;    node = node.parentElement;  }  return null;}@Component()export class StickyScroll extends StatefulComponent {  @Prop() items: StickyScrollItem[] = [];  @Prop() class?: string;  @Prop() id?: string;  @Ref<HTMLDivElement>()  containerRef!: RefType<HTMLDivElement>;  @State() _activeIndex = 0;  private _observer?: IntersectionObserver;  onMount(): void {    const container = this.containerRef.current;    if (!container) return;    const sections = Array.from(container.querySelectorAll<HTMLElement>("[data-slot=sticky-scroll-section]"));    this._observer = new IntersectionObserver(      (entries) => {        const visible = entries          .filter((entry) => entry.isIntersecting)          .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);        if (visible.length === 0) return;        const index = sections.indexOf(visible[0].target as HTMLElement);        if (index !== -1) this._activeIndex = index;      },      { root: findScrollParent(container), rootMargin: "-40% 0px -40% 0px", threshold: 0 },    );    sections.forEach((section) => { this._observer?.observe(section); });  }  onUnmount(): void {    this._observer?.disconnect();  }  render() {    return (      <div ref={this.containerRef} id={this.id} data-slot="sticky-scroll" class={cn("grid grid-cols-1 gap-8 md:grid-cols-2", this.class)}>        <div class="flex flex-col gap-32 py-12">          {this.items.map((item) => (            <div key={item.id} data-slot="sticky-scroll-section" class="flex min-h-[40vh] flex-col justify-center gap-2">              <h3 class="text-xl font-semibold text-foreground">{item.title}</h3>              <p class="text-muted-foreground">{item.description}</p>            </div>          ))}        </div>        <div class="sticky top-24 hidden h-fit rounded-xl border bg-card p-6 text-card-foreground md:block">          {this.items.map((item, i) => (            <div key={item.id} data-active={() => (this._activeIndex === i ? "" : undefined)} class="hidden data-[active]:block">              {item.content ?? item.title}            </div>          ))}        </div>      </div>    );  }}

Install the following dependencies:

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

Copy and paste the following code into your project.

sticky-scroll.tsx
import { StatefulComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component, Prop, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);class StickyScrollStyles extends Stylesheet {  $root = this.css({ display: "grid", gridTemplateColumns: "1fr", gap: "2rem" }).on("@media (min-width: 768px)", {    gridTemplateColumns: "1fr 1fr",  });  $list = this.css({ display: "flex", flexDirection: "column", gap: "8rem", padding: "3rem 0" });  $section = this.css({ display: "flex", minHeight: "40vh", flexDirection: "column", justifyContent: "center", gap: "0.5rem" });  $title = this.css({ fontSize: "1.25rem", fontWeight: 600, color: t.foreground });  $description = this.css({ color: t.mutedForeground });  $panel = this.css({    position: "sticky",    top: "6rem",    display: "none",    height: "fit-content",    borderRadius: "0.75rem",    border: `1px solid ${t.border}`,    backgroundColor: t.card,    color: t.cardForeground,    padding: "1.5rem",  }).on("@media (min-width: 768px)", { display: "block" });  $panelItem = this.css({ display: "none" }).on('&[data-active]', { display: "block" });}export interface StickyScrollItem {  id: string;  title: string;  description: string;  content?: Children;}export interface StickyScrollProps {  items: StickyScrollItem[];  class?: string;  id?: string;}// `IntersectionObserver`'s `rootMargin` percentages resolve against `root`'s own box, not the// document — leaving `root` unset (page viewport) makes the "-40% ... -40%" band drift away from// the actual visible area whenever `StickyScroll` sits inside a nested scroll container (any// scrollable ancestor before `<body>`), and that drift changes with the viewport size.function findScrollParent(el: HTMLElement): HTMLElement | null {  let node = el.parentElement;  while (node && node !== document.body) {    const { overflowY } = getComputedStyle(node);    if (overflowY === "auto" || overflowY === "scroll") return node;    node = node.parentElement;  }  return null;}@Component()export class StickyScroll extends StatefulComponent {  @Styled(StickyScrollStyles) $s!: StickyScrollStyles;  @Prop() items: StickyScrollItem[] = [];  @Prop() class?: string;  @Prop() id?: string;  @Ref<HTMLDivElement>()  containerRef!: RefType<HTMLDivElement>;  @State() _activeIndex = 0;  private _observer?: IntersectionObserver;  onMount(): void {    const container = this.containerRef.current;    if (!container) return;    const sections = Array.from(container.querySelectorAll<HTMLElement>("[data-slot=sticky-scroll-section]"));    this._observer = new IntersectionObserver(      (entries) => {        const visible = entries          .filter((entry) => entry.isIntersecting)          .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);        if (visible.length === 0) return;        const index = sections.indexOf(visible[0].target as HTMLElement);        if (index !== -1) this._activeIndex = index;      },      { root: findScrollParent(container), rootMargin: "-40% 0px -40% 0px", threshold: 0 },    );    sections.forEach((section) => { this._observer?.observe(section); });  }  onUnmount(): void {    this._observer?.disconnect();  }  render() {    return (      <div ref={this.containerRef} id={this.id} data-slot="sticky-scroll" class={cx(this.$s.$root, this.class)}>        <div class={this.$s.$list}>          {this.items.map((item) => (            <div key={item.id} data-slot="sticky-scroll-section" class={this.$s.$section}>              <h3 class={this.$s.$title}>{item.title}</h3>              <p class={this.$s.$description}>{item.description}</p>            </div>          ))}        </div>        <div class={this.$s.$panel}>          {this.items.map((item, i) => (            <div key={item.id} data-active={() => (this._activeIndex === i ? "" : undefined)} class={this.$s.$panelItem}>              {item.content ?? item.title}            </div>          ))}        </div>      </div>    );  }}

Examples

About

Purely presentational + an IntersectionObserver — no Morphos equivalent. The left column is a plain scrolling list of sections; the right column stays sticky and shows whichever section is currently centered in the viewport. Every panel is rendered once and toggled via a data-active attribute (not swapped in/out), matching how the rest of the registry drives per-item reactive state through data attributes rather than re-rendering children.

Usage

import { StickyScroll, type StickyScrollItem } from "@/components/ui/sticky-scroll";

const items: StickyScrollItem[] = [
  { id: "1", title: "Collect", description: "Gather data from every source in one pipeline." },
  { id: "2", title: "Transform", description: "Clean and reshape it with a declarative pipeline." },
];

<StickyScroll items={items} />

Props

PropTypeDefault
itemsStickyScrollItem[]
classstring

StickyScrollItem is { id: string; title: string; description: string; content?: Children }content overrides what the sticky panel shows for that item (defaults to its title).

On this page