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.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/Draggable.min.js"></script>Code
<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>.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);
}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
| Name | Type | Default | Description |
|---|---|---|---|
| data-flick-cards-init | string | "" | 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-collection | string | "" | Add to the absolutely positioned collection container that overlays the invisible spacer element and holds the card list. |
| data-flick-cards-list | string | "" | Add to the flex container that holds all card items. The script queries this for all [data-flick-cards-item] elements. |
| data-flick-cards-item | string | "" | 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-dragger | string | auto | Added 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; }