LISN.js

Edit on StackBlitz

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

import {
  ScrollWatcher,
  ViewWatcher,
  ScrollTrigger,
  ScrollData,
  Hide,
  Show,
  Enable,
} from "lisn.js";
import {
  useScrollbar,
  CollapsibleComponent,
  CollapsibleTriggerComponent,
  PopupComponent,
  PopupTriggerComponent,
  ModalComponent,
  OffcanvasComponent,
  ScrollToTopComponent,
  OffcanvasComponentRef,
  ModalComponentRef,
} from "@lisn.js/react";

// import "lisn.js/collapsible.css";
// import "lisn.js/popup.css";
// import "lisn.js/modal.css";
// import "lisn.js/offcanvas.css";
// import "lisn.js/scrollbar.css";
// import "lisn.js/scroll-to-top.css";
// ^^ Or we could just import "lisn.js/lisn.css" which contains all CSS
import "lisn.js/lisn.css";

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

export default function Page() {
  useScrollbar();

  return (
    <>
      <ScrollToTopComponent />
      <DemoMenu />
      <DemoModal />

      <div className={styles.demo}>
        <DemoIntro />

        {/* blank page for spacing */}
        <div className={styles.section}></div>

        <div
          className={[styles.section, styles.top, styles.accordion].join(" ")}
        >
          <h1 className={styles.huge}>Why LISN?</h1>
          <CollapsibleComponent config={{ peek: "75px" }}>
            <DemoAccordion title="Lightweight.">
              <p>Vanilla TypeScript</p>
              <p>Highly optimized</p>
              <DemoTooltip title="No layout thrashing">
                Correct use of <code>requestAnimationFrame</code> to completely
                eliminate forced re-layouts and run smoothly even on mobile
                devices.
              </DemoTooltip>
            </DemoAccordion>

            <DemoAccordion title="Interactive.">
              <p>Powerful API</p>
              <DemoTooltip title="Multi gesture support">
                Take actions based on user gestures. Scroll, zoom or drag, using
                wheel, touch or pointer device? Any or all of these.
              </DemoTooltip>
              <p>Mobile/touch ready</p>
            </DemoAccordion>

            <DemoAccordion title="Simple.">
              <p>Intuitive syntax</p>
              <p>Consistent API</p>
              <DemoTooltip title="HTML-only mode">
                The HTML-only API can do much of what the full JavaScript API
                can.
              </DemoTooltip>
            </DemoAccordion>

            <DemoAccordion title="No-nonsense.">
              <p>What says on the box</p>
              <DemoTooltip title="Sensible defaults">
                Spend time building your site, not configuring LISN.
              </DemoTooltip>
              <DemoTooltip title="Highly customizable">
                But if you want to tweak... go wild.
              </DemoTooltip>
            </DemoAccordion>
          </CollapsibleComponent>

          {/* trigger will be wrapped and we set the class on the wrapper */}
          <CollapsibleTriggerComponent config={{ className: styles.trigger }}>
            <h5 className={styles.more}>~~ View more ~~</h5>
            <h5 className={styles.less}>~~ View less ~~</h5>
          </CollapsibleTriggerComponent>
        </div>
      </div>
    </>
  );
}

const DemoMenu = () => {
  const menuWidgetRef = useRef<OffcanvasComponentRef>(null);

  useEffect(() => {
    // open/close menu on scroll up/down
    const watcher = ScrollWatcher.reuse();
    const handler = (t: EventTarget, scrollData: ScrollData) => {
      const widget = menuWidgetRef.current?.getWidget();
      if (widget) {
        if (scrollData.direction === "up") {
          widget.open();
        } else if (scrollData.direction === "down") {
          widget.close();
        }
      }
    };

    watcher.onScroll(handler);

    return () => {
      // cleanup
      watcher.offScroll(handler);
    };
  }, []);

  {
    /* Remember, openable components, if not explicitly passed a map/array of
     * triggers, will try to automatically find triggers in the parent element.
     * So either:
     * - wrap it in it own element,
     * - explicitly pass triggers (in this case we want an empty array since it
     *   will be opened by the view watcher) or
     * - explicitly pass contentId
     */
  }
  return (
    <OffcanvasComponent
      className={styles.menu}
      config={{ position: "top", closeButton: false, triggers: [] }}
      widgetRef={menuWidgetRef}
    >
      <a href="/demos" target="_blank">
        More demos
      </a>
      <a href="/#get-started" target="_blank">
        Getting started
      </a>
      <a href="https://github.com/lisnjs/lisn.js" target="_blank">
        Source code
      </a>
    </OffcanvasComponent>
  );
};

const DemoModal = () => {
  const modalWidgetRef = useRef<ModalComponentRef>(null);

  useEffect(() => {
    // show modal when scrolled to the middle of the page
    const watcher = ViewWatcher.create({ rootMargin: "-48% 0px" });
    const target = "top: 50%";
    const handler = () => {
      const widget = modalWidgetRef.current?.getWidget();
      widget?.open();
      watcher.offView(target, handler); // just once
    };
    watcher.onView(target, handler, { views: "at" });

    return () => {
      // cleanup
      watcher.offView(target, handler);
    };
  }, []);

  {
    /* Remember, openable components, if not explicitly passed a map/array of
     * triggers, will try to automatically find triggers in the parent element.
     * So either:
     * - wrap it in it own element,
     * - explicitly pass triggers (in this case we want an empty array since it
     *   will be opened by the view watcher) or
     * - explicitly pass contentId
     */
  }
  return (
    <ModalComponent
      className={styles.modal}
      widgetRef={modalWidgetRef}
      config={{ triggers: [] }}
    >
      You&apos;ve reached the middle of the page. Are you liking LISN already?
    </ModalComponent>
  );
};

const DemoIntro = () => {
  const msg1Ref = useRef(null);
  const msg2Ref = useRef(null);

  useEffect(() => {
    // Show/hide scroll instruction messages
    const msg1 = msg1Ref.current;
    const msg2 = msg2Ref.current;
    const triggers: ScrollTrigger[] = [];
    if (msg1 && msg2) {
      // On scroll up, hide the "Scroll up" message and shortly after enable
      // the trigger on the other message
      triggers.push(
        new ScrollTrigger(msg1, [new Hide(msg1)], {
          directions: "up",
          once: true,
        }),
      );

      triggers.push(
        new ScrollTrigger(
          msg2,
          [new Show(msg2), new Enable(msg2, "hide on down")],
          {
            directions: "up",
            once: true,
            delay: 300,
          },
        ),
      );

      // Logically, we would use Hide on direction down, but this will set the
      // initial state of the element to be shown, so we reverse the logic.
      triggers.push(
        new ScrollTrigger(msg2, [new Show(msg2)], {
          directions: "up",
          once: true,
          id: "hide on down",
        }),
      );
    }

    return () => {
      // cleanup
      for (const trigger of triggers) {
        trigger.destroy();
      }
    };
  }, []);

  return (
    <>
      <div className={styles.section}>
        <p className="text-center">
          This demo includes all four types of openables with several types of
          triggers for opening them.
        </p>
        <h2>Scroll down.</h2>
      </div>

      <div className={styles.section}>
        <h2 ref={msg2Ref} className="lisn-hide">
          Now keep going down.
        </h2>
        <div className={styles.spacer}></div>
        <h2 ref={msg1Ref} className="lisn-hide">
          Then scroll up a bit.
        </h2>
      </div>
    </>
  );
};

const DemoAccordion = ({
  title,
  children,
}: {
  title: string;
  children: ReactNode;
}) => {
  return (
    <div>
      <CollapsibleTriggerComponent
        as="h4"
        className={styles.trigger}
        config={{
          icon: "left",
          iconClosed: "plus",
          iconOpen: "minus",
        }}
      >
        {title}
      </CollapsibleTriggerComponent>

      <CollapsibleComponent
        className={styles.content}
        config={{ autoClose: true }}
      >
        {children}
      </CollapsibleComponent>
    </div>
  );
};

const DemoTooltip = ({
  title,
  children,
}: {
  title: string;
  children: ReactNode;
}) => {
  return (
    <div>
      <PopupTriggerComponent
        as="p"
        className={styles.tooltip}
        config={{ hover: true }}
      >
        {title}
      </PopupTriggerComponent>
      <PopupComponent className={styles.popup} config={{ position: "top" }}>
        {children}
      </PopupComponent>
    </div>
  );
};
.demo {
  padding: 1em;
}

.huge {
  font-size: 2.5em;
}

/* Layout */
.section {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

.section.top {
  justify-content: flex-start;
}

.spacer {
  margin: 50px 0;
}

/* Collapsibles */
.trigger[data-lisn-is-open="false"] .less {
  display: none;
}

.trigger[data-lisn-is-open="true"] .more {
  display: none;
}

.trigger :is(h1, h2, h3, h4, h5, h6) {
  margin: 5px 0;
}

.demo p {
  margin: 3px 0;
}

.accordion .content {
  padding: 1em 0 1em 1.3em;
}

/* Popups */
.demo {
  --lisn-popup--v-padding: 15px;
  --lisn-popup--h-padding: 15px;
  --lisn-popup--width: 350px;
  --lisn-popup--max-width: 98vw;
}

.popup {
  font-size: 16px;
}

.tooltip::after {
  content: "?";
  display: inline-block;
  border: solid 1px currentColor;
  border-radius: 50%;
  width: 11px;
  height: 11px;
  font-size: 9px;
  line-height: 12px;
  text-align: center;
  margin-left: 5px;
  transform: translateY(-9px);
}

/* Offcanvas menu */
.menu {
  display: flex;
  justify-content: space-around;
  flex-wrap: wrap;
  gap: 1.5em;
  font-weight: bold;
  font-size: 22px;
}

/* Modal */
.modal {
  text-align: center;
}

Edit on CodePen

document.addEventListener("DOMContentLoaded", () => {
  const main = document.getElementById("demo");

  // Setup menu, modal and the their scroll and view triggers.
  // In what follows we've used the triggers JavaScript API but we could
  // have equally used the respective watchers.
  const menu = document.getElementById("demo-menu");
  new LISN.widgets.Offcanvas(menu, {
    position: "top",
    closeButton: false,
  });
  new LISN.triggers.ScrollTrigger(menu, [new LISN.actions.Open(menu)], {
    directions: "up",
  });

  const modal = document.getElementById("demo-modal");
  new LISN.widgets.Modal(modal);
  new LISN.triggers.ViewTrigger(modal, [new LISN.actions.Open(modal)], {
    target: "top: 50%",
    rootMargin: "-48%, 0px",
    once: true,
  });

  // Setup the triggers for the messages.
  const msg1 = document.getElementById("msg1");
  new LISN.triggers.ScrollTrigger(msg1, [new LISN.actions.Hide(msg1)], {
    directions: "up",
    once: true,
  });

  const msg2 = document.getElementById("msg2");
  new LISN.triggers.ScrollTrigger(
    msg2,
    [
      new LISN.actions.Show(msg2),
      new LISN.actions.Enable(msg2, "hide-on-down"),
    ],
    {
      directions: "up",
      once: true,
      delay: 300,
    },
  );
  // We could use Hide on direction down, but this will set the initial
  // state of the element to be shown, so we reverse the logic.
  new LISN.triggers.ScrollTrigger(msg2, [new LISN.actions.Show(msg2)], {
    directions: "up",
    once: true,
    id: "hide-on-down",
  });

  // Setup the collapsibles.
  const peekable = main.querySelector(".peekable");
  const peekableTrigger = main.querySelector(".peekable-trigger");
  new LISN.widgets.Collapsible(peekable, {
    triggers: new Map([
      // transfer the class also to the trigger wrapper so we can target it with
      // .peekable-trigger[data-lisn-is-open="false"]
      [peekableTrigger, { className: "peekable-trigger" }],
    ]),
    peek: "75px",
  });

  for (const item of main.querySelectorAll(".accordion-item")) {
    const trigger = item.querySelector(".accordion-item-trigger");
    const content = item.querySelector(".accordion-item-content");
    new LISN.widgets.Collapsible(content, {
      triggers: [trigger],
      autoClose: true,
      // We can set the trigger properties either here or on a per-trigger
      // basis.
      icon: "left",
      iconClosed: "plus",
      iconOpen: "minus",
    });
  }

  // Setup the popups.
  for (const item of main.querySelectorAll(".popup")) {
    const trigger = item.querySelector(".popup-trigger");
    const content = item.querySelector(".popup-content");
    new LISN.widgets.Popup(content, {
      triggers: new Map([[trigger, { hover: true }]]),
      position: "top",
    });
  }
});
<div id="demo">
  <div id="demo-menu">
    <a>More demos</a>
    <a>Getting started</a>
    <a>Source code</a>
  </div>

  <div id="demo-modal">
    You've reached the middle of the page. Are you liking LISN already?
  </div>

  <div class="section">
    <p class="text-center">
      This demo includes all four types of openables with several types of
      triggers for opening them.
    </p>
    <h2>Scroll down.</h2>
  </div>

  <div class="section">
    <h2 id="msg2">Now keep going down.</h2>
    <div class="spacer"></div>
    <h2 id="msg1">Then scroll up a bit.</h2>
  </div>

  <!-- blank page for spacing -->
  <div class="section"></div>

  <div class="section top accordions">
    <h1 class="huge">Why LISN?</h1>
    <div class="peekable">
      <div class="accordion-item">
        <h4 class="accordion-item-trigger">Lightweight.</h4>

        <div class="accordion-item-content">
          <div>
            <p>Vanilla TypeScript</p>
          </div>

          <div>
            <p>Highly optimized</p>
          </div>

          <div class="popup">
            <p class="popup-trigger">No layout thrashing</p>
            <div class="popup-content">
              Correct use of <code>requestAnimationFrame</code> to
              completely eliminate forced re-layouts and run smoothly even
              on mobile devices.
            </div>
          </div>
        </div>
      </div>

      <div class="accordion-item">
        <h4 class="accordion-item-trigger">Interactive.</h4>

        <div class="accordion-item-content">
          <div>
            <p>Powerful API</p>
          </div>

          <div class="popup">
            <p class="popup-trigger">Multi gesture support</p>
            <div class="popup-content">
              Take actions based on user gestures. Scroll, zoom or drag,
              using wheel, touch or pointer device? Any or all of these.
            </div>
          </div>

          <div>
            <p>Mobile/touch ready</p>
          </div>
        </div>
      </div>

      <div class="accordion-item">
        <h4 class="accordion-item-trigger">Simple.</h4>

        <div class="accordion-item-content">
          <div>
            <p>Intuitive syntax</p>
          </div>

          <div>
            <p>Consistent API</p>
          </div>

          <div class="popup">
            <p class="popup-trigger">HTML-only mode</p>
            <div class="popup-content">
              The HTML-only API can do much of what the full JavaScript API
              can.
            </div>
          </div>
        </div>
      </div>

      <div class="accordion-item">
        <h4 class="accordion-item-trigger">No-nonsense.</h4>

        <div class="accordion-item-content">
          <div>
            <p>What says on the box</p>
          </div>

          <div class="popup">
            <p class="popup-trigger">Sensible defaults</p>
            <div class="popup-content">
              Spend time building your site, not configuring LISN.
            </div>
          </div>

          <div class="popup">
            <p class="popup-trigger">Highly customizable</p>
            <div class="popup-content">
              But if you want to tweak... go wild.
            </div>
          </div>
        </div>
      </div>
    </div>

    <div class="peekable-trigger">
      <h5 class="more">~~ View more ~~</h5>
      <h5 class="less">~~ View less ~~</h5>
    </div>
  </div>
</div>
#demo .huge {
  font-size: 2.5em;
}

/* Layout */
#demo .section {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

#demo .section.top {
  justify-content: flex-start;
}

#demo .spacer {
  margin: 50px 0;
}

/* Collapsibles */
#demo .peekable-trigger[data-lisn-is-open="false"] .less {
  display: none;
}

#demo .peekable-trigger[data-lisn-is-open="true"] .more {
  display: none;
}

#demo .accordion-item-trigger :is(h1, h2, h3, h4, h5, h6) {
  margin: 5px 0;
}

#demo p {
  margin: 3px 0;
}

#demo .accordion-item-content {
  padding: 1em 0 1em 1.3em;
}

/* Popups */
#demo {
  --lisn-popup--v-padding: 15px;
  --lisn-popup--h-padding: 15px;
  --lisn-popup--width: 350px;
  --lisn-popup--max-width: 98vw;
}

#demo .popup-content {
  font-size: 16px;
}

#demo .popup-trigger::after {
  content: "?";
  display: inline-block;
  border: solid 1px currentColor;
  border-radius: 50%;
  width: 11px;
  height: 11px;
  font-size: 9px;
  line-height: 12px;
  text-align: center;
  margin-left: 5px;
  transform: translateY(-9px);
}

/* Offcanvas menu */
#demo-menu {
  display: flex;
  justify-content: space-around;
  flex-wrap: wrap;
  gap: 1.5em;
  font-weight: bold;
  font-size: 22px;
}

/* Modal */
#demo-modal {
  text-align: center;
}

Edit on CodePen

<div data-lisn-scroll-to-top></div>
<div id="demo">
  <div
    id="demo-menu"
    data-lisn-offcanvas="position=top | close-button=false"
    data-lisn-on-scroll="up @open"
  >
    <a>More demos</a>
    <a>Getting started</a>
    <a>Source code</a>
  </div>

  <div
    id="demo-modal"
    data-lisn-modal
    data-lisn-on-view="@open +target=top: 50%
                       +rootMargin=-48%,0px +once"
  >
    You've reached the middle of the page. Are you liking LISN already?
  </div>

  <div class="section">
    <p class="text-center">
      This demo includes all four types of openables with several types of
      triggers for opening them.
    </p>
    <h2>Scroll down.</h2>
  </div>

  <div class="section">
    <!-- use the @show action for both triggers so that the initial state is hidden -->
    <h2
      data-lisn-on-scroll="up @show +once +id=hide-on-down ;
                           up @show @enable:hide-on-down +once +delay=300"
    >
      Now keep going down.
    </h2>
    <div class="spacer"></div>
    <h2 data-lisn-on-scroll="up @hide +once">Then scroll up a bit.</h2>
  </div>

  <!-- blank page for spacing -->
  <div class="section"></div>

  <div class="section top accordions">
    <h1 class="huge">Why LISN?</h1>
    <div data-lisn-collapsible="peek=75px">
      <div>
        <h4
          data-lisn-collapsible-trigger="icon=left
                                         | icon-closed=plus
                                         | icon-open=minus"
        >
          Lightweight.
        </h4>

        <div data-lisn-collapsible="auto-close">
          <div>
            <p>Vanilla TypeScript</p>
          </div>

          <div>
            <p>Highly optimized</p>
          </div>

          <div>
            <p data-lisn-popup-trigger="hover">No layout thrashing</p>
            <div data-lisn-popup="position=top">
              Correct use of <code>requestAnimationFrame</code> to
              completely eliminate forced re-layouts and run smoothly even
              on mobile devices.
            </div>
          </div>
        </div>
      </div>

      <div>
        <h4
          data-lisn-collapsible-trigger="icon=left
                                         | icon-closed=plus
                                         | icon-open=minus"
        >
          Interactive.
        </h4>

        <div data-lisn-collapsible="auto-close">
          <div>
            <p>Powerful API</p>
          </div>

          <div>
            <p data-lisn-popup-trigger="hover">Multi gesture support</p>
            <div data-lisn-popup="position=top">
              Take actions based on user gestures. Scroll, zoom or drag,
              using wheel, touch or pointer device? Any or all of these.
            </div>
          </div>

          <div>
            <p>Mobile/touch ready</p>
          </div>
        </div>
      </div>

      <div>
        <h4
          data-lisn-collapsible-trigger="icon=left
                                         | icon-closed=plus
                                         | icon-open=minus"
        >
          Simple.
        </h4>

        <div data-lisn-collapsible="auto-close">
          <div>
            <p>Intuitive syntax</p>
          </div>

          <div>
            <p>Consistent API</p>
          </div>

          <div>
            <p data-lisn-popup-trigger="hover">HTML-only mode</p>
            <div data-lisn-popup="position=top">
              The HTML-only API can do much of what the full JavaScript API
              can.
            </div>
          </div>
        </div>
      </div>

      <div>
        <h4
          data-lisn-collapsible-trigger="icon=left
                                         | icon-closed=plus
                                         | icon-open=minus"
        >
          No-nonsense.
        </h4>

        <div data-lisn-collapsible="auto-close">
          <div>
            <p>What says on the box</p>
          </div>

          <div>
            <p data-lisn-popup-trigger="hover">Sensible defaults</p>
            <div data-lisn-popup="position=top">
              Spend time building your site, not configuring LISN.
            </div>
          </div>

          <div>
            <p data-lisn-popup-trigger="hover">Highly customizable</p>
            <div data-lisn-popup="position=top">
              But if you want to tweak... go wild.
            </div>
          </div>
        </div>
      </div>
    </div>

    <div data-lisn-collapsible-trigger="class-name=peekable-trigger">
      <h5 class="more">~~ View more ~~</h5>
      <h5 class="less">~~ View less ~~</h5>
    </div>
  </div>
</div>
#demo .huge {
  font-size: 2.5em;
}

/* Layout */
#demo .section {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

#demo .section.top {
  justify-content: flex-start;
}

#demo .spacer {
  margin: 50px 0;
}

/* Collapsibles */
#demo .peekable-trigger[data-lisn-is-open="false"] .less {
  display: none;
}

#demo .peekable-trigger[data-lisn-is-open="true"] .more {
  display: none;
}

#demo .accordion-item-trigger :is(h1, h2, h3, h4, h5, h6) {
  margin: 5px 0;
}

#demo p {
  margin: 3px 0;
}

#demo .accordion-item-content {
  padding: 1em 0 1em 1.3em;
}

/* Popups */
#demo {
  --lisn-popup--v-padding: 15px;
  --lisn-popup--h-padding: 15px;
  --lisn-popup--width: 350px;
  --lisn-popup--max-width: 98vw;
}

#demo [data-lisn-popup] {
  font-size: 16px;
}

#demo [data-lisn-popup-trigger]::after {
  content: "?";
  display: inline-block;
  border: solid 1px currentColor;
  border-radius: 50%;
  width: 11px;
  height: 11px;
  font-size: 9px;
  line-height: 12px;
  text-align: center;
  margin-left: 5px;
  transform: translateY(-9px);
}

/* Offcanvas menu */
#demo-menu {
  display: flex;
  justify-content: space-around;
  flex-wrap: wrap;
  gap: 1.5em;
  font-weight: bold;
  font-size: 22px;
}

/* Modal */
#demo-modal {
  text-align: center;
}
More demosGetting startedSource code
You've reached the middle of the page. Are you liking LISN already?

This demo includes all four types of openables with several types of triggers for opening them.

Scroll down.

Now keep going down.

Then scroll up a bit.

Why LISN?

Lightweight.

Vanilla TypeScript

Highly optimized

No layout thrashing

Correct use of requestAnimationFrame to completely eliminate forced re-layouts and run smoothly even on mobile devices.

Interactive.

Powerful API

Multi gesture support

Take actions based on user gestures. Scroll, zoom or drag, using wheel, touch or pointer device? Any or all of these.

Mobile/touch ready

Simple.

Intuitive syntax

Consistent API

HTML-only mode

The HTML-only API can do much of what the full JavaScript API can.

No-nonsense.

What says on the box

Sensible defaults

Spend time building your site, not configuring LISN.

Highly customizable

But if you want to tweak... go wild.
~~ View more ~~
~~ View less ~~