Sortable Cards ✦ turbokit.io

4 min read Original article ↗
              
                // --- 📦 Imports ---
import { animate, hover, press } from "https://esm.sh/motion";
import { Howl } from "https://esm.sh/howler";
import { Sortable, Plugins } from "https://esm.sh/@shopify/draggable";

// --- 📱 Touch Support ---
const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0;

// --- 🔊 Audio Helper ---
let playAudio = true,
  ended = true,
  currentSound;

// ▶️ Play audio file with optional volume/speed
const play = async (file, volume = 0.5, speed = 1) => {
  const context = new (AudioContext || webkitAudioContext)();
  await context.resume().catch(() => {});
  if (context.state === "suspended") return false;

  currentSound?.stop(); // stop any current sound
  ended = false;

  currentSound = new Howl({
    src: [file],
    autoplay: playAudio,
    volume,
    rate: speed,
    onend: () => {
      ended = true;
      currentSound = null;
    }
  });

  return true;
};

// --- 🧱 DOM Elements ---
const cards = document.querySelector(".cards");
const made = document.querySelector(".made");

// --- 🏷️ "Made by" UI animation ---
animate(made, { scale: [0.75, 1], opacity: [0, 0.75] }, { delay: 0.25 });

hover(made, () => {
  animate(
    made,
    { scale: 1.02, opacity: 1 },
    { type: "spring", stiffness: 200 }
  );
  return () => animate(made, { scale: 1, opacity: 0.75 }, { type: "spring" });
});

// --- 🧲 Sortable Cards Init ---
const sortable = new Sortable(cards, {
  draggable: ".card-wrap",
  handle: isTouchDevice ? null : ".card-handle",
  plugins: [Plugins.SortAnimation]
});

// ❌ Cancel mirror on drag end + animate disappearance
sortable.on("mirror:destroy", (e) => {
  e.cancel();
  const r = e.source.getBoundingClientRect();
  animate(
    e.mirror,
    {
      x: r.left,
      y: r.top,
      filter: "blur(2rem)",
      opacity: 0,
      scale: 0.5
    },
    {
      x: { duration: 0.3 },
      y: { duration: 0.3 },
      filter: { duration: 0.5 },
      opacity: { duration: 0.5 },
      scale: { duration: 0.5 },
      onComplete: () => e.mirror.remove()
    }
  );
});

// 🟡 Play drag start sound
sortable.on("sortable:start", () => {
  play("https://cdn.freesound.org/previews/582/582898_5965684-lq.mp3");
  document.body.classList.add("grabbing");
});

// 🟢 Play drop sound and animate dropped card
sortable.on("sortable:stop", (e) => {
  document.body.classList.remove("grabbing");
  play("https://cdn.freesound.org/previews/370/370962_5450487-lq.mp3");

  requestAnimationFrame(() => {
    animate(
      e.data.newContainer.children[e.data.newIndex],
      { scale: [1.1, 1] },
      { type: "spring", stiffness: 200, damping: 10 }
    );
  });
});

// --- ✨ Animate Each Card ---
[...cards.children].forEach((card, i) => {
  const bg = card.querySelector(".card-bg");
  const fade = card.querySelector(".card-fade");
  const button = card.querySelector(".card-button");
  const cardUI = card.querySelector(".card");
  const handle = card.querySelector(".card-handle");
  const name = card.querySelector(".card-name");
  const description = card.querySelector(".card-description");

  // 🖱️ Hover on card - fade + button color
  hover(card, () => {
    animate(fade, { opacity: 1 });
    animate(button, { background: "#FF004D" });
    return () => {
      animate(fade, { opacity: 0.75 });
      animate(button, { background: "rgba(255, 255, 255, 0.2)" });
    };
  });

  // 🎬 Card Fade-in Animation
  animate(
    cardUI,
    {
      opacity: [0, 1],
      filter: ["blur(1rem)", "blur(0)"]
    },
    {
      duration: 0.8,
      delay: i / 4
    }
  );

  // 📝 Animate text entries
  animate(name, { y: [20, 0], opacity: [0, 1] }, { delay: i / 4 + 0.3 });
  animate(
    description,
    { y: [20, 0], opacity: [0, 0.4] },
    { delay: i / 4 + 0.5 }
  );

  // 🌌 Background Pulse
  animate(
    bg,
    {
      scale: [1, 1.2]
    },
    {
      duration: 15,
      repeat: Infinity,
      repeatType: "reverse",
      ease: "linear"
    }
  );

  // 👉 Button Press Feedback
  press(button, () => {
    animate(button, { scale: 1.1 });
    return () => animate(button, { scale: 1 });
  });

  // 🎯 Handle Hover - zoom in effect
  hover(handle, () => {
    animate(handle, { scale: 0.95 });
    animate(bg, { scale: 1.1 }, { type: "spring", stiffness: 50, damping: 10 });
    animate(
      card,
      { scale: 1.025 },
      { type: "spring", stiffness: 100, damping: 10 }
    );

    // 🔁 Reset scale + resume bg animation
    return () => {
      animate(
        card,
        { scale: 1 },
        { type: "spring", stiffness: 100, damping: 10 }
      );
      animate(handle, { scale: 1 });
      animate(
        bg,
        { scale: 1 },
        { type: "spring", stiffness: 50, damping: 10 }
      ).then(() => {
        animate(
          bg,
          {
            scale: [1, 1.2]
          },
          {
            duration: 15,
            repeat: Infinity,
            repeatType: "reverse",
            ease: "linear"
          }
        );
      });
    };
  });
});

// --- 🏷️ Footer animation ---
const follow = document.querySelector(".follow");
animate(follow, { scale: [0.75, 1], opacity: [0, 0.75] }, { delay: 0.25 });

hover(follow, () => {
  animate(
    follow,
    { scale: 1.03, opacity: 1 },
    { type: "spring", stiffness: 200 }
  );
  return () => animate(follow, { scale: 1, opacity: 0.75 }, { type: "spring" });
});

              
            

!