Flick Cards Slider

A stacked card slider where dragging flicks through cards with elastic snap animations, progressive opacity and scale based on layer depth, and CSS-attribute-driven state for styling active and adjacent cards.

slidercardsgsapdraggablestackedflickelastictouch

Setup — External Scripts

GSAP 3.13.0
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
Draggable Plugin
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/Draggable.min.js"></script>

Code

index.html
html
<div data-flick-cards-init="" class="flick-group">
  <div class="flick-group__relative-object">
    <div class="flick-group__relative-object-before"></div>
  </div>
  <div data-flick-cards-collection="" class="flick-group__collection">
    <div data-flick-cards-list="" class="flick-group__list">
      <div data-flick-cards-item-status="" data-flick-cards-item="" class="flick-group__item">
        <div class="flick-card">
          <div class="flick-card__before"></div>
          <div class="flick-card__media">
            <img width="256" loading="lazy" alt="" src="https://cdn.prod.website-files.com/684ab5dddc581e3b1766332f/687df8b27e36f68b959afd96_slider-image-1.avif" class="cover-image">
            <a href="#" class="flick-card__btn"><span class="flick-card__btn-span">Get this product</span></a>
            <h3 class="flick-card__h3">FX100</h3>
          </div>
        </div>
      </div>
      <div data-flick-cards-item-status="" data-flick-cards-item="" class="flick-group__item">
        <div class="flick-card">
          <div class="flick-card__before"></div>
          <div class="flick-card__media">
            <img width="256" loading="lazy" alt="" src="https://cdn.prod.website-files.com/684ab5dddc581e3b1766332f/687df8b21d85143b1c286d20_slider-image-8.avif" class="cover-image">
            <a href="#" class="flick-card__btn"><span class="flick-card__btn-span">Get this product</span></a>
            <h3 class="flick-card__h3">LX200</h3>
          </div>
        </div>
      </div>
      <div data-flick-cards-item-status="" data-flick-cards-item="" class="flick-group__item">
        <div class="flick-card">
          <div class="flick-card__before"></div>
          <div class="flick-card__media">
            <img width="256" loading="lazy" alt="" src="https://cdn.prod.website-files.com/684ab5dddc581e3b1766332f/687df8b18ade0b890d5ab1fb_slider-image-5.avif" class="cover-image">
            <a href="#" class="flick-card__btn"><span class="flick-card__btn-span">Get this product</span></a>
            <h3 class="flick-card__h3">TX5</h3>
          </div>
        </div>
      </div>
      <div data-flick-cards-item-status="" data-flick-cards-item="" class="flick-group__item">
        <div class="flick-card">
          <div class="flick-card__before"></div>
          <div class="flick-card__media">
            <img width="256" loading="lazy" alt="" src="https://cdn.prod.website-files.com/684ab5dddc581e3b1766332f/687df8b263e10957f9c08e32_slider-image-4.avif" class="cover-image">
            <a href="#" class="flick-card__btn"><span class="flick-card__btn-span">Get this product</span></a>
            <h3 class="flick-card__h3">NX400</h3>
          </div>
        </div>
      </div>
      <div data-flick-cards-item-status="" data-flick-cards-item="" class="flick-group__item">
        <div class="flick-card">
          <div class="flick-card__before"></div>
          <div class="flick-card__media">
            <img width="256" loading="lazy" alt="" src="https://cdn.prod.website-files.com/684ab5dddc581e3b1766332f/687df8b288a95313e0dd5d6a_slider-image-2.avif" class="cover-image">
            <a href="#" class="flick-card__btn"><span class="flick-card__btn-span">Get this product</span></a>
            <h3 class="flick-card__h3">TX9</h3>
          </div>
        </div>
      </div>
      <div data-flick-cards-item-status="" data-flick-cards-item="" class="flick-group__item">
        <div class="flick-card">
          <div class="flick-card__before"></div>
          <div class="flick-card__media">
            <img width="256" loading="lazy" alt="" src="https://cdn.prod.website-files.com/684ab5dddc581e3b1766332f/687df8b2c53e6feaa864a913_slider-image-3.avif" class="cover-image">
            <a href="#" class="flick-card__btn"><span class="flick-card__btn-span">Get this product</span></a>
            <h3 class="flick-card__h3">RX300</h3>
          </div>
        </div>
      </div>
      <div data-flick-cards-item-status="" data-flick-cards-item="" class="flick-group__item">
        <div class="flick-card">
          <div class="flick-card__before"></div>
          <div class="flick-card__media">
            <img width="256" loading="lazy" alt="" src="https://cdn.prod.website-files.com/684ab5dddc581e3b1766332f/687df8b2527cd8ea76517edb_slider-image-7.avif" class="cover-image">
            <a href="#" class="flick-card__btn"><span class="flick-card__btn-span">Get this product</span></a>
            <h3 class="flick-card__h3">KX120</h3>
          </div>
        </div>
      </div>
      <div data-flick-cards-item-status="" data-flick-cards-item="" class="flick-group__item">
        <div class="flick-card">
          <div class="flick-card__before"></div>
          <div class="flick-card__media">
            <img width="256" loading="lazy" alt="" src="https://cdn.prod.website-files.com/684ab5dddc581e3b1766332f/687df8b2974f10a7083f8698_slider-image-6.avif" class="cover-image">
            <a href="#" class="flick-card__btn"><span class="flick-card__btn-span">Get this product</span></a>
            <h3 class="flick-card__h3">MX60</h3>
          </div>
        </div>
      </div>
      <div data-flick-cards-item-status="" data-flick-cards-item="" class="flick-group__item">
        <div class="flick-card">
          <div class="flick-card__before"></div>
          <div class="flick-card__media">
            <img width="256" loading="lazy" alt="" src="https://cdn.prod.website-files.com/684ab5dddc581e3b1766332f/687df8b2617e89ca885628e5_slider-image-9.avif" class="cover-image">
            <a href="#" class="flick-card__btn"><span class="flick-card__btn-span">Get this product</span></a>
            <h3 class="flick-card__h3">ZV210</h3>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
styles.css
css
.flick-group {
  position: relative;
}

.flick-group__relative-object {
  opacity: 0;
  pointer-events: none;
  width: 47em;
  position: relative;
}

.flick-group__relative-object-before {
  padding-top: 75%;
}

.flick-group__collection {
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}

.flick-group__list {
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  display: flex;
  position: relative;
}

.flick-group__item {
  position: absolute;
}

.flick-card {
  color: #fff;
  -webkit-user-select: none;
  user-select: none;
  background-color: #000;
  border-radius: 1em;
  justify-content: center;
  align-items: center;
  width: 23.5em;
  display: flex;
  position: relative;
  overflow: hidden;
}

.flick-card__before {
  padding-top: 150%;
}

.flick-card__media {
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  display: flex;
  position: absolute;
  top: 0;
  left: 0;
}

.cover-image {
  pointer-events: none;
  object-fit: cover;
  -webkit-user-select: none;
  user-select: none;
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: auto;
}

.flick-card__h3 {
  letter-spacing: -.025em;
  font-size: 4em;
  font-weight: 500;
  line-height: 1;
  position: absolute;
}

.flick-card__btn {
  background-color: #000;
  border-radius: .375em;
  justify-content: center;
  align-items: center;
  width: calc(100% - 4em);
  height: 3.25em;
  text-decoration: none;
  display: flex;
  position: absolute;
  bottom: 2em;
  left: 2em;
}

.flick-card__btn-span {
  color: #fff;
  font-size: 1em;
  font-weight: 500;
}

[data-flick-cards-dragger] {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: auto;
  touch-action: pan-y;
}

/* Position Slides */
[data-flick-cards-item-status] .flick-card__media {
  transition: opacity 0.2s ease;
  opacity: 0.5;
}

[data-flick-cards-item-status="2-before"] .flick-card__media,
[data-flick-cards-item-status="2-after"] .flick-card__media {
  transition: opacity 0.2s ease;
  opacity: 0.75;
}

[data-flick-cards-item-status="active"] .flick-card__media {
  opacity: 1;
}

/* Animate Button */
[data-flick-cards-item-status] .flick-card__btn {
  transition: opacity 0.4s cubic-bezier(0.625, 0.05, 0, 1), transform 1s cubic-bezier(0.16, 1, 0.3, 1);
  opacity: 0;
  transform: translate(0%, 50%) rotate(0.001deg);
}

[data-flick-cards-item-status="active"] .flick-card__btn {
  opacity: 1;
  transform: translate(0%, 0%) rotate(0.001deg);
}
script.js
javascript
gsap.registerPlugin(Draggable);

function initFlickCards() {
  const sliders = document.querySelectorAll('[data-flick-cards-init]');

  sliders.forEach(slider => {
    const list = slider.querySelector('[data-flick-cards-list]');
    const cards = Array.from(list.querySelectorAll('[data-flick-cards-item]'));
    const total = cards.length;
    let activeIndex = 0;

    const sliderWidth = slider.offsetWidth;
    const threshold = 0.1;

    // Generate draggers inside each card and store references
    const draggers = [];
    cards.forEach(card => {
      const dragger = document.createElement('div');
      dragger.setAttribute('data-flick-cards-dragger', '');
      card.appendChild(dragger);
      draggers.push(dragger);
    });

    // Set initial drag status
    slider.setAttribute('data-flick-drag-status', 'grab');

    function getConfig(i, currentIndex) {
      let diff = i - currentIndex;
      if (diff > total / 2) diff -= total;
      else if (diff < -total / 2) diff += total;

      switch (diff) {
        case  0: return { x: 0,   y: 0, rot: 0,   s: 1,   o: 1, z: 5 };
        case  1: return { x: 25,  y: 1, rot: 10,  s: 0.9, o: 1, z: 4 };
        case -1: return { x: -25, y: 1, rot: -10, s: 0.9, o: 1, z: 4 };
        case  2: return { x: 45,  y: 5, rot: 15,  s: 0.8, o: 1, z: 3 };
        case -2: return { x: -45, y: 5, rot: -15, s: 0.8, o: 1, z: 3 };
        default:
          const dir = diff > 0 ? 1 : -1;
          return { x: 55 * dir, y: 5, rot: 20 * dir, s: 0.6, o: 0, z: 2 };
      }
    }

    function renderCards(currentIndex) {
      cards.forEach((card, i) => {
        const cfg = getConfig(i, currentIndex);
        let status;

        if (cfg.x === 0)        status = 'active';
        else if (cfg.x === 25)  status = '2-after';
        else if (cfg.x === -25) status = '2-before';
        else if (cfg.x === 45)  status = '3-after';
        else if (cfg.x === -45) status = '3-before';
        else                    status = 'hidden';

        card.setAttribute('data-flick-cards-item-status', status);
        card.style.zIndex = cfg.z;

        gsap.to(card, {
          duration: 0.6,
          ease: 'elastic.out(1.2, 1)',
          xPercent: cfg.x,
          yPercent: cfg.y,
          rotation: cfg.rot,
          scale: cfg.s,
          opacity: cfg.o
        });
      });
    }

    renderCards(activeIndex);

    if (total < 7) {
      console.log('Not minimum of 7 cards');
      return;
    }

    let pressClientX = 0;
    let pressClientY = 0;

    Draggable.create(draggers, {
      type: 'x',
      edgeResistance: 0.8,
      bounds: { minX: -sliderWidth / 2, maxX: sliderWidth / 2 },
      inertia: false,

      onPress() {
        pressClientX = this.pointerEvent.clientX;
        pressClientY = this.pointerEvent.clientY;
        slider.setAttribute('data-flick-drag-status', 'grabbing');
      },

      onDrag() {
        const rawProgress = this.x / sliderWidth;
        const progress = Math.min(1, Math.abs(rawProgress));
        const direction = rawProgress > 0 ? -1 : 1;
        const nextIndex = (activeIndex + direction + total) % total;

        cards.forEach((card, i) => {
          const from = getConfig(i, activeIndex);
          const to = getConfig(i, nextIndex);
          const mix = prop => from[prop] + (to[prop] - from[prop]) * progress;

          gsap.set(card, {
            xPercent: mix('x'),
            yPercent: mix('y'),
            rotation: mix('rot'),
            scale: mix('s'),
            opacity: mix('o')
          });
        });
      },

      onRelease() {
        slider.setAttribute('data-flick-drag-status', 'grab');

        const releaseClientX = this.pointerEvent.clientX;
        const releaseClientY = this.pointerEvent.clientY;
        const dragDistance = Math.hypot(releaseClientX - pressClientX, releaseClientY - pressClientY);

        const raw = this.x / sliderWidth;
        let shift = 0;
        if (raw > threshold) shift = -1;
        else if (raw < -threshold) shift = 1;

        if (shift !== 0) {
          activeIndex = (activeIndex + shift + total) % total;
          renderCards(activeIndex);
        }

        gsap.to(this.target, {
          x: 0,
          duration: 0.3,
          ease: 'power1.out'
        });

        if (dragDistance < 4) {
          // Temporarily allow clicks to pass through
          this.target.style.pointerEvents = 'none';

          // Allow the DOM to register pointer-through
          requestAnimationFrame(() => {
            requestAnimationFrame(() => {
              const el = document.elementFromPoint(releaseClientX, releaseClientY);
              if (el) {
                const evt = new MouseEvent('click', {
                  view: window,
                  bubbles: true,
                  cancelable: true
                });
                el.dispatchEvent(evt);
              }

              // Restore pointer events
              this.target.style.pointerEvents = 'auto';
            });
          });
        }
      }
    });
  });
}

// Initialize Flick Cards Slider
document.addEventListener('DOMContentLoaded', function() {
  initFlickCards();
});

Attributes

NameTypeDefaultDescription
data-flick-cards-initstring""Add to the outermost wrapper to initialize a Flick Cards slider instance. Multiple instances on the same page are supported — each is set up independently.
data-flick-cards-collectionstring""Add to the absolutely positioned collection container that overlays the invisible spacer element and holds the card list.
data-flick-cards-liststring""Add to the flex container that holds all card items. The script queries this for all [data-flick-cards-item] elements.
data-flick-cards-itemstring""Add to each card wrapper. Cards are positioned absolutely and transformed by the script based on their index relative to the active card.
data-flick-cards-item-status"active" | "2-before" | "2-after" | "3-before" | "3-after" | "hidden"""Set automatically by the script on each card. Use these values in CSS attribute selectors to style cards based on their layer position.
data-flick-drag-status"grab" | "grabbing""grab"Set on the init wrapper. Updates to "grabbing" while the user is dragging. Use this in CSS to change the cursor or apply visual feedback during interaction.
data-flick-cards-draggerstringautoAdded automatically by the script to an invisible overlay div appended inside each card. This is the Draggable target — it intercepts pointer events and passes clicks through when the drag distance is less than 4px.

Notes

  • A minimum of 7 cards is required for drag interaction to be enabled. With fewer cards the positions will still render, but the Draggable setup is skipped and a console warning is logged.
  • The .flick-group__relative-object element is an invisible spacer that defines the height of the slider. Its padding-top on the child sets the aspect ratio — adjust the width and padding-top percentage to resize the slider area.
  • Each card is positioned absolutely and stacked in the centre. GSAP xPercent values shift cards left or right based on their diff from the active index.
  • During drag, card positions are linearly interpolated between the current state and the next-index state using a progress value derived from drag distance / slider width.
  • On release, if the drag crosses the threshold (default 10% of slider width) the active index advances or retreats. If the drag distance is under 4px it is treated as a tap and a synthetic click is dispatched to the element under the pointer.
  • The invisible dragger overlay uses touch-action: pan-y so vertical scroll is preserved on touch devices while horizontal drags are handled by Draggable.
  • Card button animations (translate + opacity) are driven entirely by CSS using the data-flick-cards-item-status attribute — no extra JS is needed to animate them.

Guide

Wrapper & spacer

Add [data-flick-cards-init] to the outermost wrapper. Inside it, place an invisible .flick-group__relative-object element — its padding-top percentage sets the slider's aspect ratio and gives the absolute-positioned collection a height to fill.

Collection, List & Items

Add [data-flick-cards-collection] to the absolute overlay container, [data-flick-cards-list] to the flex list inside it, and [data-flick-cards-item] to each card wrapper. Cards are centred and stacked via position: absolute on the item.

Card structure

Inside each [data-flick-cards-item] place a .flick-card with a padding-top spacer (.flick-card__before) to set the card aspect ratio, and a .flick-card__media absolutely positioned over it for images and interactive elements.

Layer status & CSS styling

The script writes data-flick-cards-item-status to each card with values: active, 2-before, 2-after, 3-before, 3-after, hidden. Use these as CSS attribute selectors to control opacity, pointer events, or any other per-layer visual treatment.

[data-flick-cards-item-status="active"] .flick-card__media { opacity: 1; }
[data-flick-cards-item-status="2-before"] .flick-card__media,
[data-flick-cards-item-status="2-after"] .flick-card__media { opacity: 0.75; }

Drag threshold

The threshold variable (default 0.1) sets the fraction of slider width the user must drag before the active index changes. Increase it toward 0.3–0.5 for a stiffer feel; decrease toward 0.05 for hair-trigger sensitivity.

Drag status cursor

The init wrapper receives data-flick-drag-status="grab" at rest and data-flick-drag-status="grabbing" during a drag. Use CSS attribute selectors on the wrapper to switch the cursor property.

[data-flick-drag-status="grab"] { cursor: grab; }
[data-flick-drag-status="grabbing"] { cursor: grabbing; }