LISN.js

Edit on StackBlitz

"use client";
import { useEffect, useRef, MouseEventHandler } from "react";

import {
  ViewWatcher,
  ScrollWatcher,
  LoadTrigger,
  Show,
  AutoHide,
  ViewData,
} from "lisn.js";
import { ScrollbarComponent, ScrollbarComponentRef } from "@lisn.js/react";
import "lisn.js/scrollbar.css";

import styles from "./demo.module.css";

export default function Page() {
  const msgRef = useRef<HTMLParagraphElement>(null);
  const scrollableRef = useRef<ScrollbarComponentRef>(null);
  const triggerRefs = useRef<Element[]>([]);
  const tabRefs = useRef<Element[]>([]);

  const addTriggerRef = (trigger: Element | null) => {
    if (trigger) {
      triggerRefs.current.push(trigger);
    }
  };

  const addTabRef = (tab: Element | null) => {
    if (tab) {
      tabRefs.current.push(tab);
    }
  };

  const getTabForTrigger = (trigger: Element) =>
    tabRefs.current[triggerRefs.current.indexOf(trigger)];

  const getTriggerForTab = (tab: Element) =>
    triggerRefs.current[tabRefs.current.indexOf(tab)];

  const onTabSelect: MouseEventHandler<HTMLDivElement> = (event) => {
    const tab = event.target;
    const scrollable = scrollableRef.current?.getWidget()?.getScrollable();
    if (scrollable && tab instanceof Element) {
      const trigger = getTriggerForTab(tab);
      if (trigger) {
        ScrollWatcher.reuse().scrollTo(trigger, {
          scrollable: scrollable,
          offset: { top: -5 },
        });
      }
    }
  };

  useEffect(() => {
    let viewWatcher: ViewWatcher;
    const scrollable = scrollableRef.current?.getWidget()?.getScrollable();
    const triggers = [...triggerRefs.current];

    const onViewHandler = (trigger: Element, viewData: ViewData) => {
      const tab = getTabForTrigger(trigger);
      if (tab) {
        if (viewData.views[0] === "at") {
          tab.classList.add(styles.inview);
        } else {
          tab.classList.remove(styles.inview);
        }
      }
    };

    if (scrollable) {
      viewWatcher = ViewWatcher.create({
        root: scrollable,
        rootMargin: "0px",
      });

      for (const trigger of triggers) {
        viewWatcher.onView(trigger, onViewHandler);
      }
    }

    return () => {
      // cleanup
      for (const trigger of triggers) {
        viewWatcher?.offView(trigger, onViewHandler);
      }
    };
  }, []);

  useEffect(() => {
    const msg = msgRef.current;
    let widget: AutoHide;
    if (msg) {
      new LoadTrigger(msg, [new Show(msg)], {
        delay: 1000,
      });
      widget = new AutoHide(msg, { delay: 2500 });
    }

    return () => {
      // cleanup
      widget?.destroy();
    };
  }, []);

  return (
    <>
      <div className={styles.wrapper}>
        <p ref={msgRef} className={[styles.msg, "lisn-hide"].join(" ")}>
          Scroll the box
        </p>

        <div className={styles.demo}>
          <div className={styles.tabs}>
            <div ref={addTabRef} onClick={onTabSelect} className={styles.tab}>
              L
            </div>
            <div ref={addTabRef} onClick={onTabSelect} className={styles.tab}>
              I
            </div>
            <div ref={addTabRef} onClick={onTabSelect} className={styles.tab}>
              S
            </div>
            <div ref={addTabRef} onClick={onTabSelect} className={styles.tab}>
              N
            </div>
          </div>

          <ScrollbarComponent
            widgetRef={scrollableRef}
            className={styles.scrollable}
          >
            <div ref={addTriggerRef} className={styles.trigger}></div>
            <div className={styles.section}>
              <h1>L</h1>
              <h4>Lightweight.</h4>

              <ul>
                <li>Vanilla TypeScript</li>
                <li>Highly optimized</li>
                <li>No layout thrashing</li>
              </ul>
            </div>

            <div ref={addTriggerRef} className={styles.trigger}></div>
            <div className={styles.section}>
              <h1>I</h1>
              <h4>Interactive.</h4>

              <ul>
                <li>Powerful API</li>
                <li>Multi gesture support</li>
                <li>Mobile/touch ready</li>
              </ul>
            </div>

            <div ref={addTriggerRef} className={styles.trigger}></div>
            <div className={styles.section}>
              <h1>S</h1>
              <h4>Simple.</h4>

              <ul>
                <li>Intuitive syntax</li>
                <li>Consistent API</li>
                <li>HTML-only mode</li>
              </ul>
            </div>

            <div ref={addTriggerRef} className={styles.trigger}></div>
            <div className={styles.section}>
              <h1>N</h1>
              <h4>No-nonsense.</h4>

              <ul>
                <li>What says on the box</li>
                <li>Sensible defaults</li>
                <li>Highly customizable</li>
              </ul>
            </div>
          </ScrollbarComponent>
        </div>
      </div>
    </>
  );
}
/* Scrollable */
.wrapper {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  width: 100dvw;
  height: 100vh;
  height: 100dvh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}

.demo {
  width: 100%;
}

.scrollable {
  height: 400px;
  width: 800px;
  max-width: 100%;
  background: var(--bg-color-lighter);
  box-shadow: var(--lisn-shadow);
  color: var(--text-color);
  overflow: auto;
  margin: 0 auto;
}

.trigger {
  transform: translateY(5px);
}

/* Tabs */
.tabs {
  display: flex;
  justify-content: center;
}

.tab {
  font-size: 22px;
  color: var(--text-color);
  padding: 10px;
  transform: scale(0.9);
  transition-property: transform;
  transition-duration: 0.3s;
  cursor: pointer;
}

.tab::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 2px;
  background: var(--text-color);
  transform: scaleX(0);
  transition-property: transform;
  transition-duration: 0.3s;
}

.tab.inview {
  color: var(--text-color-lighter);
  font-weight: bold;
  transform: scale(1);
}

.tab.inview::after {
  transform: scaleX(1);
}

/* Content */
.section {
  height: 400px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

/* Misc styles */
.demo h1 {
  margin: 0 auto;
  font-size: 75px;
  background-image: linear-gradient(
    45deg,
    var(--text-color) 42%,
    var(--text-color-lighter) 50%,
    var(--text-color) 58%
  );
  background-clip: text;
  -webkit-background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
}

.demo h4 {
  font-size: clamp(20px, calc(15px + 2vw), 32px);
}

.demo ul {
  padding: 0;
  list-style-type: none;
}

.demo ul li {
  margin: 8px 0;
}

.demo ul li::before {
  content: "\2726";
  display: inline-block;
  font-size: 0.4em;
  transform: translateY(-0.4em);
  margin: 0 0.6em 0 0;
}

Edit on CodePen

document.addEventListener("DOMContentLoaded", () => {
  const main = document.getElementById("demo");
  const scrollable = main.querySelector(".scrollable");
  const tabs = main.querySelectorAll(".tab");
  const triggerLines = scrollable.querySelectorAll(".trigger-line");

  new LISN.widgets.Scrollbar(scrollable);

  const watcher = LISN.watchers.ViewWatcher.create({
    root: scrollable,
    rootMargin: "0px",
  });
  for (let i = 0; i < triggerLines.length; i++) {
    const tab = tabs[i];
    if (tab) {
      watcher.onView(triggerLines[i], (e, viewData) => {
        if (viewData.views[0] === "at") {
          tab.classList.add("inview");
        } else {
          tab.classList.remove("inview");
        }
      });

      tab.addEventListener("click", () =>
        LISN.watchers.ScrollWatcher.reuse().scrollTo(triggerLines[i], {
          scrollable: scrollable,
          offset: { top: -5 },
        }),
      );
    }
  }

  const msg = document.getElementById("msg");
  new LISN.triggers.LoadTrigger(msg, [new LISN.actions.Show(msg)], {
    delay: 1000,
  });
  new LISN.widgets.AutoHide(msg, { delay: 1500 });
});
<p id="msg" class="lisn-hide">Scroll the box</p>

<div id="demo">
  <div class="tabs">
    <div class="tab">L</div>
    <div class="tab">I</div>
    <div class="tab">S</div>
    <div class="tab">N</div>
  </div>

  <div class="scrollable">
    <div class="trigger-line"></div>
    <div class="section">
      <h1>L</h1>
      <h4>Lightweight.</h4>

      <ul>
        <li>Vanilla TypeScript</li>
        <li>Highly optimized</li>
        <li>No layout thrashing</li>
      </ul>
    </div>

    <div class="trigger-line"></div>
    <div class="section">
      <h1>I</h1>
      <h4>Interactive.</h4>

      <ul>
        <li>Powerful API</li>
        <li>Multi gesture support</li>
        <li>Mobile/touch ready</li>
      </ul>
    </div>

    <div class="trigger-line"></div>
    <div class="section">
      <h1>S</h1>
      <h4>Simple.</h4>

      <ul>
        <li>Intuitive syntax</li>
        <li>Consistent API</li>
        <li>HTML-only mode</li>
      </ul>
    </div>

    <div class="trigger-line"></div>
    <div class="section">
      <h1>N</h1>
      <h4>No-nonsense.</h4>

      <ul>
        <li>What says on the box</li>
        <li>Sensible defaults</li>
        <li>Highly customizable</li>
      </ul>
    </div>
  </div>
</div>
/* Scrollable */
#demo {
  width: 100%;
}

#demo .scrollable {
  height: 400px;
  width: 800px;
  max-width: 100%;
  background: #28283f;
  box-shadow: var(--lisn-shadow);
  color: var(--text-color);
  overflow: auto;
  margin: 0 auto;
}

#demo .trigger-line {
  transform: translateY(5px);
}

/* Tabs */
#demo .tabs {
  display: flex;
  justify-content: center;
}

#demo .tab {
  font-size: 22px;
  color: var(--text-color);
  padding: 10px;
  transform: scale(0.9);
  transition-property: transform;
  transition-duration: 0.3s;
  cursor: pointer;
}

#demo .tab::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 2px;
  background: var(--text-color);
  transform: scaleX(0);
  transition-property: transform;
  transition-duration: 0.3s;
}

#demo .tab.inview {
  color: var(--text-color-lighter);
  font-weight: bold;
  transform: scale(1);
}

#demo .tab.inview::after {
  transform: scaleX(1);
}

/* Content */
#demo .section {
  height: 400px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

#demo h1 {
  margin: 0 auto;
  font-size: 75px;
  background-image: linear-gradient(
    45deg,
    var(--text-color) 42%,
    var(--text-color-lighter) 50%,
    var(--text-color) 58%
  );
  background-clip: text;
  -webkit-background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
}

#demo h4 {
  font-size: clamp(20px, calc(15px + 2vw), 32px);
}

#demo ul {
  padding: 0;
  list-style-type: none;
}

#demo ul li {
  margin: 8px 0;
}

#demo ul li::before {
  content: "\2726";
  display: inline-block;
  font-size: 0.4em;
  transform: translateY(-0.4em);
  margin: 0 0.6em 0 0;
}

Edit on CodePen

<p
  class="lisn-hide"
  data-lisn-on-load="@show +delay=1000"
  data-lisn-auto-hide="1500"
>
  Scroll the box
</p>

<div id="demo">
  <div class="tabs">
    <div
      class="tab"
      data-lisn-ref="tab-L"
      data-lisn-on-view="at @add-class:inview
                         +root=next.scrollable
                         +rootMargin=0px
                         +target=next-section-L"
    >
      L
    </div>
    <div
      class="tab"
      data-lisn-ref="tab-I"
      data-lisn-on-view="at @add-class:inview
                         +root=next.scrollable
                         +rootMargin=0px
                         +target=next-section-I"
    >
      I
    </div>
    <div
      class="tab"
      data-lisn-ref="tab-S"
      data-lisn-on-view="at @add-class:inview
                         +root=next.scrollable
                         +rootMargin=0px
                         +target=next-section-S"
    >
      S
    </div>
    <div
      class="tab"
      data-lisn-ref="tab-N"
      data-lisn-on-view="at @add-class:inview
                         +root=next.scrollable
                         +rootMargin=0px
                         +target=next-section-N"
    >
      N
    </div>
  </div>

  <div class="scrollable" data-lisn-scrollbar>
    <div
      class="trigger-line"
      data-lisn-ref="section-L"
      data-lisn-on-click="@scroll-to: offsetY=-5, scrollable=this.scrollable
                          +target=prev-tab-L +oneWay"
    ></div>
    <div class="section">
      <h1>L</h1>
      <h4>Lightweight.</h4>

      <ul>
        <li>Vanilla TypeScript</li>
        <li>Highly optimized</li>
        <li>No layout thrashing</li>
      </ul>
    </div>

    <div
      class="trigger-line"
      data-lisn-ref="section-I"
      data-lisn-on-click="@scroll-to: offsetY=-5, scrollable=this.scrollable
                          +target=prev-tab-I +oneWay"
    ></div>
    <div class="section">
      <h1>I</h1>
      <h4>Interactive.</h4>

      <ul>
        <li>Powerful API</li>
        <li>Multi gesture support</li>
        <li>Mobile/touch ready</li>
      </ul>
    </div>

    <div
      class="trigger-line"
      data-lisn-ref="section-S"
      data-lisn-on-click="@scroll-to: offsetY=-5, scrollable=this.scrollable
                          +target=prev-tab-S +oneWay"
    ></div>
    <div class="section">
      <h1>S</h1>
      <h4>Simple.</h4>

      <ul>
        <li>Intuitive syntax</li>
        <li>Consistent API</li>
        <li>HTML-only mode</li>
      </ul>
    </div>

    <div
      class="trigger-line"
      data-lisn-ref="section-N"
      data-lisn-on-click="@scroll-to: offsetY=-5, scrollable=this.scrollable
                          +target=prev-tab-N +oneWay"
    ></div>
    <div class="section">
      <h1>N</h1>
      <h4>No-nonsense.</h4>

      <ul>
        <li>What says on the box</li>
        <li>Sensible defaults</li>
        <li>Highly customizable</li>
      </ul>
    </div>
  </div>
</div>
/* Scrollable */
#demo {
  width: 100%;
}

#demo .scrollable {
  height: 400px;
  width: 800px;
  max-width: 100%;
  background: #28283f;
  box-shadow: var(--lisn-shadow);
  color: var(--text-color);
  overflow: auto;
  margin: 0 auto;
}

#demo .trigger-line {
  transform: translateY(5px);
}

/* Tabs */
#demo .tabs {
  display: flex;
  justify-content: center;
}

#demo .tab {
  font-size: 22px;
  color: var(--text-color);
  padding: 10px;
  transform: scale(0.9);
  transition-property: transform;
  transition-duration: 0.3s;
  cursor: pointer;
}

#demo .tab::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 2px;
  background: var(--text-color);
  transform: scaleX(0);
  transition-property: transform;
  transition-duration: 0.3s;
}

#demo .tab.inview {
  color: var(--text-color-lighter);
  font-weight: bold;
  transform: scale(1);
}

#demo .tab.inview::after {
  transform: scaleX(1);
}

/* Content */
#demo .section {
  height: 400px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

#demo h1 {
  margin: 0 auto;
  font-size: 75px;
  background-image: linear-gradient(
    45deg,
    var(--text-color) 42%,
    var(--text-color-lighter) 50%,
    var(--text-color) 58%
  );
  background-clip: text;
  -webkit-background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
}

#demo h4 {
  font-size: clamp(20px, calc(15px + 2vw), 32px);
}

#demo ul {
  padding: 0;
  list-style-type: none;
}

#demo ul li {
  margin: 8px 0;
}

#demo ul li::before {
  content: "\2726";
  display: inline-block;
  font-size: 0.4em;
  transform: translateY(-0.4em);
  margin: 0 0.6em 0 0;
}

Scroll the box

L
I
S
N

L

Lightweight.

  • Vanilla TypeScript
  • Highly optimized
  • No layout thrashing

I

Interactive.

  • Powerful API
  • Multi gesture support
  • Mobile/touch ready

S

Simple.

  • Intuitive syntax
  • Consistent API
  • HTML-only mode

N

No-nonsense.

  • What says on the box
  • Sensible defaults
  • Highly customizable