SectionsAdvancedApril 9, 2026

Testimonial Slider

Infinite draggable testimonial carousel powered by GSAP's horizontalLoop utility. Supports autoplay with scroll-trigger pause/resume, inertia-based drag with snapping, bullet avatar navigation, prev/next controls, and an orange corner-bracket accent on the active slide.

View Full Demo →

Working with No-5 transformed our digital presence entirely. The motion design language they built feels completely native to our brand.

Sarah ChenSarah Chen

The attention to detail is unmatched. Every scroll, hover, and transition was considered. Our bounce rate dropped 40% after launch.

Marcus ReedMarcus Reed

From first call to launch, the process was seamless. They pushed back when needed and the result was better for it.

Aisha WilliamsAisha Williams

The interactions we got were genuinely beyond what we thought was possible on the web. Clients mention the site constantly.

Jordan ParkJordan Park

A studio that understands both craft and performance. The site looks stunning and loads in under two seconds.

Elena TorresElena Torres
demo.jsx
import TestimonialSlider from "./index.jsx";
import { testimonialSlider } from "@/content/sections/demo-data.js";
import styles from "./demo.module.css";

export default function TestimonialSliderDemo() {
  return (
    <div className={styles.demo}>
      <div className={styles.heading}>
        <p className={styles.label}>What clients say</p>
        <h2 className={styles.title}>Kind words</h2>
      </div>
      <TestimonialSlider {...testimonialSlider} />
    </div>
  );
}
index.jsx
"use client";

import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { Draggable } from "gsap/Draggable";
import { CustomEase } from "gsap/CustomEase";
import { InertiaPlugin } from "gsap/InertiaPlugin";
import styles from "./styles.module.css";

gsap.registerPlugin(CustomEase, ScrollTrigger, Draggable, InertiaPlugin);
CustomEase.create("osmo-ease", "0.625, 0.05, 0, 1");

function horizontalLoop(items, config) {
  let timeline;
  items = gsap.utils.toArray(items);
  config = config || {};
  gsap.context(() => {
    let onChange = config.onChange,
      lastIndex = 0,
      tl = gsap.timeline({
        repeat: config.repeat,
        onUpdate:
          onChange &&
          function () {
            let i = tl.closestIndex();
            if (lastIndex !== i) {
              lastIndex = i;
              onChange(items[i], i);
            }
          },
        paused: config.paused,
        defaults: { ease: "none" },
        onReverseComplete: () =>
          tl.totalTime(tl.rawTime() + tl.duration() * 100),
      }),
      length = items.length,
      startX = items[0].offsetLeft,
      times = [],
      widths = [],
      spaceBefore = [],
      xPercents = [],
      curIndex = 0,
      indexIsDirty = false,
      center = config.center,
      pixelsPerSecond = (config.speed || 1) * 100,
      snap =
        config.snap === false ? (v) => v : gsap.utils.snap(config.snap || 1),
      timeOffset = 0,
      container =
        center === true
          ? items[0].parentNode
          : gsap.utils.toArray(center)[0] || items[0].parentNode,
      totalWidth,
      getTotalWidth = () =>
        items[length - 1].offsetLeft +
        (xPercents[length - 1] / 100) * widths[length - 1] -
        startX +
        spaceBefore[0] +
        items[length - 1].offsetWidth *
          gsap.getProperty(items[length - 1], "scaleX") +
        (parseFloat(config.paddingRight) || 0),
      populateWidths = () => {
        let b1 = container.getBoundingClientRect(), b2;
        items.forEach((el, i) => {
          widths[i] = parseFloat(gsap.getProperty(el, "width", "px"));
          xPercents[i] = snap(
            (parseFloat(gsap.getProperty(el, "x", "px")) / widths[i]) * 100 +
              gsap.getProperty(el, "xPercent")
          );
          b2 = el.getBoundingClientRect();
          spaceBefore[i] = b2.left - (i ? b1.right : b1.left);
          b1 = b2;
        });
        gsap.set(items, { xPercent: (i) => xPercents[i] });
        totalWidth = getTotalWidth();
      },
      timeWrap,
      populateOffsets = () => {
        timeOffset = center
          ? (tl.duration() * (container.offsetWidth / 2)) / totalWidth
          : 0;
        center &&
          times.forEach((t, i) => {
            times[i] = timeWrap(
              tl.labels["label" + i] +
                tl.duration() * (widths[i] / 2 / totalWidth) -
                timeOffset
            );
          });
      },
      getClosest = (values, value, wrap) => {
        let i = values.length, closest = 1e10, index = 0, d;
        while (i--) {
          d = Math.abs(values[i] - value);
          if (d > wrap / 2) d = wrap - d;
          if (d < closest) { closest = d; index = i; }
        }
        return index;
      },
      populateTimeline = () => {
        let i, item, curX, distanceToStart, distanceToLoop;
        tl.clear();
        for (i = 0; i < length; i++) {
          item = items[i];
          curX = (xPercents[i] / 100) * widths[i];
          distanceToStart = item.offsetLeft + curX - startX + spaceBefore[0];
          distanceToLoop = distanceToStart + widths[i] * gsap.getProperty(item, "scaleX");
          tl.to(item, {
            xPercent: snap(((curX - distanceToLoop) / widths[i]) * 100),
            duration: distanceToLoop / pixelsPerSecond,
          }, 0)
            .fromTo(item,
              { xPercent: snap(((curX - distanceToLoop + totalWidth) / widths[i]) * 100) },
              {
                xPercent: xPercents[i],
                duration: (curX - distanceToLoop + totalWidth - curX) / pixelsPerSecond,
                immediateRender: false,
              },
              distanceToLoop / pixelsPerSecond
            )
            .add("label" + i, distanceToStart / pixelsPerSecond);
          times[i] = distanceToStart / pixelsPerSecond;
        }
        timeWrap = gsap.utils.wrap(0, tl.duration());
      },
      refresh = (deep) => {
        let progress = tl.progress();
        tl.progress(0, true);
        populateWidths();
        deep && populateTimeline();
        populateOffsets();
        deep && tl.draggable
          ? tl.time(times[curIndex], true)
          : tl.progress(progress, true);
      },
      onResize = () => refresh(true),
      proxy;

    gsap.set(items, { x: 0 });
    populateWidths();
    populateTimeline();
    populateOffsets();
    window.addEventListener("resize", onResize);

    function toIndex(index, vars) {
      vars = vars || {};
      Math.abs(index - curIndex) > length / 2 &&
        (index += index > curIndex ? -length : length);
      let newIndex = gsap.utils.wrap(0, length, index),
        time = times[newIndex];
      if (time > tl.time() !== index > curIndex && index !== curIndex) {
        time += tl.duration() * (index > curIndex ? 1 : -1);
      }
      if (time < 0 || time > tl.duration()) {
        vars.modifiers = { time: timeWrap };
      }
      curIndex = newIndex;
      vars.overwrite = true;
      gsap.killTweensOf(proxy);
      return vars.duration === 0
        ? tl.time(timeWrap(time))
        : tl.tweenTo(time, vars);
    }

    tl.toIndex = (index, vars) => toIndex(index, vars);
    tl.closestIndex = (setCurrent) => {
      let index = getClosest(times, tl.time(), tl.duration());
      if (setCurrent) { curIndex = index; indexIsDirty = false; }
      return index;
    };
    tl.current = () => indexIsDirty ? tl.closestIndex(true) : curIndex;
    tl.next = (vars) => toIndex(tl.current() + 1, vars);
    tl.previous = (vars) => toIndex(tl.current() - 1, vars);
    tl.times = times;
    tl.progress(1, true).progress(0, true);

    if (config.reversed) {
      tl.vars.onReverseComplete();
      tl.reverse();
    }

    if (config.draggable && typeof Draggable === "function") {
      proxy = document.createElement("div");
      let wrap = gsap.utils.wrap(0, 1),
        ratio, startProgress, draggable, lastSnap, initChangeX, wasPlaying,
        align = () => tl.progress(wrap(startProgress + (draggable.startX - draggable.x) * ratio)),
        syncIndex = () => tl.closestIndex(true);

      draggable = Draggable.create(proxy, {
        trigger: items[0].parentNode,
        type: "x",
        onPressInit() {
          let x = this.x;
          gsap.killTweensOf(tl);
          wasPlaying = !tl.paused();
          tl.pause();
          startProgress = tl.progress();
          refresh();
          ratio = 1 / totalWidth;
          initChangeX = startProgress / -ratio - x;
          gsap.set(proxy, { x: startProgress / -ratio });
        },
        onDrag: align,
        onThrowUpdate: align,
        overshootTolerance: 0,
        inertia: true,
        snap(value) {
          if (Math.abs(startProgress / -ratio - this.x) < 10) {
            return lastSnap + initChangeX;
          }
          let time = -(value * ratio) * tl.duration(),
            wrappedTime = timeWrap(time),
            snapTime = times[getClosest(times, wrappedTime, tl.duration())],
            dif = snapTime - wrappedTime;
          Math.abs(dif) > tl.duration() / 2 &&
            (dif += dif < 0 ? tl.duration() : -tl.duration());
          lastSnap = (time + dif) / tl.duration() / -ratio;
          return lastSnap;
        },
        onRelease() {
          syncIndex();
          draggable.isThrowing && (indexIsDirty = true);
        },
        onThrowComplete: () => {
          syncIndex();
          wasPlaying && tl.play();
        },
      })[0];

      tl.draggable = draggable;
    }

    tl.closestIndex(true);
    lastIndex = curIndex;
    onChange && onChange(items[curIndex], curIndex);
    timeline = tl;

    return () => window.removeEventListener("resize", onResize);
  });

  return timeline;
}

export default function TestimonialSlider({
  slides = [],
  autoplay = true,
  autoplayDuration = 4,
}) {
  const wrapperRef = useRef(null);
  const listRef = useRef(null);

  useEffect(() => {
    const wrapper = wrapperRef.current;
    const list = listRef.current;
    if (!wrapper || !list) return;

    const slideEls = gsap.utils.toArray(list.querySelectorAll("[data-slide]"));
    const bulletEls = gsap.utils.toArray(wrapper.querySelectorAll("[data-bullet]"));
    const prevBtn = wrapper.querySelector("[data-prev]");
    const nextBtn = wrapper.querySelector("[data-next]");

    let activeSlide, activeBullet, currentIndex = 0;
    let autoplayCall = null;

    slideEls.forEach((slide, i) => slide.setAttribute("id", `slide-${i}`));
    bulletEls.forEach((bullet, i) => {
      bullet.setAttribute("aria-controls", `slide-${i}`);
      bullet.setAttribute("aria-selected", i === 0 ? "true" : "false");
    });

    const loop = horizontalLoop(slideEls, {
      paused: true,
      draggable: true,
      center: true,
      onChange: (element, index) => {
        currentIndex = index;
        if (activeSlide) activeSlide.classList.remove(styles.active);
        element.classList.add(styles.active);
        activeSlide = element;
        if (bulletEls.length) {
          if (activeBullet) activeBullet.classList.remove(styles.active);
          if (bulletEls[index]) {
            bulletEls[index].classList.add(styles.active);
            activeBullet = bulletEls[index];
          }
          bulletEls.forEach((b, i) =>
            b.setAttribute("aria-selected", i === index ? "true" : "false")
          );
        }
      },
    });

    loop.toIndex(2, { duration: 0.01 });

    function startAutoplay() {
      if (!autoplay || autoplayDuration <= 0 || autoplayCall) return;
      const repeat = () => {
        loop.next({ ease: "osmo-ease", duration: 0.725 });
        autoplayCall = gsap.delayedCall(autoplayDuration, repeat);
      };
      autoplayCall = gsap.delayedCall(autoplayDuration, repeat);
    }

    function stopAutoplay() {
      if (autoplayCall) { autoplayCall.kill(); autoplayCall = null; }
    }

    const st = ScrollTrigger.create({
      trigger: wrapper,
      start: "top bottom",
      end: "bottom top",
      onEnter: startAutoplay,
      onLeave: stopAutoplay,
      onEnterBack: startAutoplay,
      onLeaveBack: stopAutoplay,
    });

    wrapper.addEventListener("mouseenter", stopAutoplay);
    wrapper.addEventListener("mouseleave", () => {
      if (ScrollTrigger.isInViewport(wrapper)) startAutoplay();
    });

    slideEls.forEach((slide, i) => {
      slide.addEventListener("click", () =>
        loop.toIndex(i, { ease: "osmo-ease", duration: 0.725 })
      );
    });

    bulletEls.forEach((bullet, i) => {
      bullet.addEventListener("click", () =>
        loop.toIndex(i, { ease: "osmo-ease", duration: 0.725 })
      );
    });

    if (prevBtn) {
      prevBtn.addEventListener("click", () => {
        let newIndex = currentIndex - 1;
        if (newIndex < 0) newIndex = slideEls.length - 1;
        loop.toIndex(newIndex, { ease: "osmo-ease", duration: 0.725 });
      });
    }

    if (nextBtn) {
      nextBtn.addEventListener("click", () => {
        let newIndex = currentIndex + 1;
        if (newIndex >= slideEls.length) newIndex = 0;
        loop.toIndex(newIndex, { ease: "osmo-ease", duration: 0.725 });
      });
    }

    return () => {
      stopAutoplay();
      st.kill();
      loop?.kill?.();
    };
  }, [autoplay, autoplayDuration]);

  return (
    <div ref={wrapperRef} className={styles.wrapper} aria-label="Testimonial Slider">
      {/* Bullet navigation */}
      <div className={styles.controls}>
        <ul role="tablist" className={styles.bulletList}>
          {slides.map((slide, i) => (
            <li key={i}>
              <button
                data-bullet=""
                role="tab"
                aria-selected="false"
                className={styles.bullet}
              >
                <img
                  src={slide.avatarSrc}
                  alt={slide.name}
                  className={styles.bulletAvatar}
                />
              </button>
            </li>
          ))}
        </ul>
      </div>

      {/* Slide row */}
      <div className={styles.row}>
        <div ref={listRef} role="group" aria-label="slides" className={styles.list}>
          {slides.map((slide, i) => (
            <div key={i} data-slide="" className={styles.slide}>
              <div className={styles.slideInner}>
                <p className={styles.quote}>{slide.quote}</p>
                <div className={styles.details}>
                  <img
                    src={slide.avatarSrc}
                    alt={slide.name}
                    className={styles.avatar}
                  />
                  <span className={styles.name}>{slide.name}</span>
                </div>
              </div>
            </div>
          ))}
        </div>
      </div>

      {/* Prev / Next buttons */}
      <div className={styles.controls}>
        <div className={styles.buttons}>
          <button data-prev="" aria-label="previous slide" className={`${styles.button} ${styles.prev}`}>
            <svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 24 24" fill="none" className={styles.arrow}>
              <path d="M14 19L21 12L14 5" stroke="currentColor" strokeMiterlimit="10" />
              <path d="M21 12H2" stroke="currentColor" strokeMiterlimit="10" />
            </svg>
          </button>
          <button data-next="" aria-label="next slide" className={styles.button}>
            <svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 24 24" fill="none" className={styles.arrow}>
              <path d="M14 19L21 12L14 5" stroke="currentColor" strokeMiterlimit="10" />
              <path d="M21 12H2" stroke="currentColor" strokeMiterlimit="10" />
            </svg>
          </button>
        </div>
      </div>
    </div>
  );
}
demo.module.css
.demo {
  min-height: 100vh;
  background: #131313;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 5rem 0 4rem;
}

.heading {
  text-align: center;
  margin-bottom: 3rem;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.label {
  font-size: 0.75rem;
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.35);
}

.title {
  font-size: clamp(2.5rem, 5vw, 5rem);
  font-weight: 500;
  letter-spacing: -0.03em;
  line-height: 0.95;
  color: #efeeec;
}
styles.module.css
/* Local tokens */
.wrapper {
  --space-s: 0.75rem;
  --space-m: 1rem;
  --space-l: 1.25rem;
  --space-2xl: 2.5rem;
  --space-5xl: 3rem;
  --space-7xl: 5rem;
  --font-body: 1rem;
  --font-tagline: 0.75rem;

  width: 100%;
}

/* Controls row */
.controls {
  display: flex;
  justify-content: center;
  align-items: center;
}

/* Bullet navigation */
.bulletList {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: var(--space-s);
  list-style: none;
  margin: 0;
  padding: 0;
}

.bullet {
  position: relative;
  width: 2em;
  height: 2em;
  padding: 0;
  border-radius: 100em;
  background-color: transparent;
  border: none;
  cursor: pointer;
  overflow: hidden;
}

.bullet:focus {
  outline: none;
}

.bullet::after {
  content: "";
  position: absolute;
  inset: 2px;
  border-radius: 100em;
  z-index: -1;
  border: 1px solid #ff4c24;
  transition: inset 0.5s cubic-bezier(0.65, 0.05, 0, 1);
}

.bullet:hover::after,
.bullet.active::after,
.bullet:focus::after {
  inset: -5px;
}

.bulletAvatar {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 100em;
  display: block;
}

/* Slide row */
.row {
  width: 100%;
  margin-top: var(--space-2xl);
  margin-bottom: var(--space-7xl);
  padding-block: var(--space-m);
  display: flex;
  position: relative;
  overflow: clip;
}

.list {
  display: flex;
  flex-direction: row;
  justify-content: flex-start;
  align-items: center;
  width: 100%;
}

/* Slides */
.slide {
  flex: none;
  padding: var(--space-s);
  transition: opacity 0.25s cubic-bezier(0.77, 0, 0.175, 1);
  position: relative;
  cursor: pointer;
}

.list:has(.active) .slide:not(.active) {
  opacity: 0.45;
}

/* Corner accent on active slide */
.slide::after {
  --corner-size: 1em;
  --corner-width: 1px;
  --corner-gap: 0.125em;
  --corner-color: #ff4c24;
  content: "";
  position: absolute;
  inset: calc(var(--corner-gap) * -1);
  z-index: 1;
  opacity: 0;
  padding: calc(var(--corner-gap) + var(--corner-width));
  outline: var(--corner-width) solid var(--corner-color);
  outline-offset: calc(var(--corner-gap) / -1);
  mask:
    conic-gradient(
        at var(--corner-size) var(--corner-size),
        #0000 75%,
        #000 0
      )
      0 0 / calc(100% - var(--corner-size)) calc(100% - var(--corner-size)),
    linear-gradient(#000 0 0) content-box;
  transition: all 0.4s cubic-bezier(0.65, 0.05, 0, 1);
  pointer-events: none;
}

.slide.active::after {
  outline-offset: calc(-1 * var(--corner-width));
  opacity: 1;
}

.slideInner {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-5xl);
  width: 30em;
  min-height: 25em;
  padding: var(--space-2xl);
  border: 1px solid rgba(239, 238, 236, 0.1);
  background-color: rgba(239, 238, 236, 0.1);
}

.quote {
  font-size: 1.125rem;
  line-height: 1.6;
  color: #efeeec;
}

.details {
  display: flex;
  align-items: center;
  gap: var(--space-s);
}

.avatar {
  width: 2.5em;
  height: 2.5em;
  border-radius: 100em;
  object-fit: cover;
  flex-shrink: 0;
  display: block;
}

.name {
  font-size: var(--font-tagline);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: #efeeec;
}

/* Prev / Next buttons */
.buttons {
  display: flex;
  align-items: center;
  gap: var(--space-m);
}

.button {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 3em;
  height: 3em;
  padding: 0;
  background-color: rgba(239, 238, 236, 0.1);
  border: 1px solid rgba(239, 238, 236, 0.1);
  border-radius: 0.25em;
  cursor: pointer;
  transition: border-color 0.2s, background-color 0.2s;
  color: #efeeec;
}

.button:hover {
  background-color: rgba(239, 238, 236, 0.2);
  border-color: rgba(239, 238, 236, 0.25);
}

.prev {
  transform: rotate(-180deg);
}

.arrow {
  width: 1.25em;
}

@media (max-width: 479px) {
  .slide { width: 85vw; }
  .slideInner { width: 100%; }
}
  • gsap

May 11, 2026

SECTIONS

Dual Push Cards

Two-up CTA card section with scroll-driven parallax on each card's background image. Cards scale down and drift vertically as you scroll past. Glassmorphic blur buttons at bottom-left. Stacks on mobile, side-by-side grid on desktop.

May 4, 2026

SECTIONS

Portfolio Grid

A responsive portfolio showcase section with a header tagline, blinking cursor counters, a 2-up/4-col project card grid with hover-zoom images and data-label metadata, plus a full-width CTA button. Scroll-triggered fade and move-up animations via GSAP.

May 1, 2026

SECTIONS

Logo Wall Cycle

A responsive logo grid that cycles through brand logos with smooth GSAP-powered swap animations. Shows 8 logos on desktop and 6 on tablet, shuffling hidden logos into view on a timed loop. Pauses when out of viewport or tab is hidden.