Coverflow

Hello, I have one page on my site (below) I would like to create a Coverflow slider to showcase my photograph gallery as opposed to using thumbnails. The only option I saw for this was the Swiper Bric. Are there any other options to implement this effect? Thanks for your time.

I use Iconic Gallery

https://blocs.store/product/iconic-gallery/

1 Like

Thank you for the reply. I use that for my galleries too and is excellent. What I am referring to are the thumbnails I use on my gallery page to take you to each individual gallery. I would like to use a Coverflow effect as opposed to the thumbnails.

1 Like

Playing with CodePen

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Coverflow — Fixed Active Expand + Smooth Pop + Responsive</title>

  <!-- Swiper CSS (CDN) -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.css">

  <style>
    :root{
      /* Responsive sizing + animation knobs */
      --slideW: 320px;

      /* Active stays at this scale (fixed expanded mode) */
      --activeScale: 1.28;

      /* Temporary overshoot multiplier when it becomes active */
      --popExtra: 1.14;

      /* Pop duration in ms */
      --popMs: 460;
    }

    /* Bootstrap-ish breakpoints */
    @media (max-width: 991.98px){
      :root{ --slideW: 290px; --activeScale: 1.24; --popExtra: 1.13; --popMs: 440; }
    }
    @media (max-width: 767.98px){
      :root{ --slideW: 250px; --activeScale: 1.18; --popExtra: 1.12; --popMs: 420; }
    }
    @media (max-width: 575.98px){
      :root{ --slideW: 210px; --activeScale: 1.12; --popExtra: 1.10; --popMs: 380; }
    }

    html, body { height: 100%; margin: 0; }
    body{
      display:grid;
      place-items:center;
      background:#0b0f19;
      font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;
      overflow-x:hidden;
    }

    .my-coverflow{
      width:min(1100px,92vw);
      padding:44px 0 70px;
    }

    .my-coverflow .swiper-slide{ width: var(--slideW); cursor: default; }
    .my-coverflow .swiper-slide-active{ cursor: zoom-in; }

    /* We animate the IMG (Swiper owns slide transforms for coverflow) */
    .my-coverflow .swiper-slide img{
      width:100%;
      display:block;
      border-radius:16px;
      box-shadow:0 18px 60px rgba(0,0,0,.50);
      user-select:none;
      -webkit-user-drag:none;

      /* JS drives these; defaults for non-active far slides */
      opacity:.08;
      transform:translateZ(0) scale(.78);
      filter:blur(2.4px) saturate(.72) contrast(.90);

      transition:
        transform .95s cubic-bezier(.18,.90,.20,1), /* smoother pop/settle */
        opacity .55s ease,
        filter .55s ease;
    }

    .my-coverflow .swiper-button-prev,
    .my-coverflow .swiper-button-next{ color:#fff; }

    .my-coverflow .swiper-pagination-bullet{ opacity:.25; }
    .my-coverflow .swiper-pagination-bullet-active{ opacity:1; }

    /* Lightbox overlay */
    .lightbox{
      position: fixed;
      inset: 0;
      display: none;
      align-items: center;
      justify-content: center;
      background: rgba(0,0,0,.78);
      z-index: 9999;
      padding: 24px;
    }
    .lightbox.is-open{ display:flex; }

    .lightbox__panel{
      position: relative;
      max-width: min(1100px, 96vw);
      max-height: 92vh;
    }

    .lightbox__img{
      display:block;
      max-width: 100%;
      max-height: 92vh;
      border-radius: 16px;
      box-shadow: 0 24px 90px rgba(0,0,0,.65);
      transform-origin: center center;
      transform: scale(2);
      transition: transform .25s ease;
      cursor: zoom-out;
      user-select:none;
      -webkit-user-drag:none;
    }

    .lightbox__close{
      position:absolute;
      top:-10px;
      right:-10px;
      width:42px;
      height:42px;
      border:0;
      border-radius:999px;
      cursor:pointer;
      background: rgba(255,255,255,.12);
      color:#fff;
      font-size:20px;
      line-height:42px;
      text-align:center;
      backdrop-filter: blur(6px);
    }

    .lightbox__hint{
      position: fixed;
      left: 50%;
      bottom: 18px;
      transform: translateX(-50%);
      color: rgba(255,255,255,.85);
      font-size: 14px;
      text-align:center;
      pointer-events:none;
    }

    @media (prefers-reduced-motion: reduce){
      .my-coverflow .swiper-slide img,
      .lightbox__img{ transition:none; }
    }
  </style>
</head>

<body>
  <div class="swiper my-coverflow" id="coverflow">
    <div class="swiper-wrapper">
      <!-- Replace these with your own images -->
      <div class="swiper-slide"><img src="upload://3wddxKWGMCpzTeZVSM5B2boZPHQ.jpeg" alt="Gallery photo 1" loading="lazy"></div>
      <div class="swiper-slide"><img src="upload://v7NFBFydGPpj53FkDFEolfFWh8N.jpeg" alt="Gallery photo 2" loading="lazy"></div>
      <div class="swiper-slide"><img src="upload://w4hPzJHdMXI4GFHVrkV8jEOvIHP.jpeg" alt="Gallery photo 3" loading="lazy"></div>
      <div class="swiper-slide"><img src="upload://vOUMW3eWnfTkC4108v8zqEvyEw.jpeg" alt="Gallery photo 4" loading="lazy"></div>
      <div class="swiper-slide"><img src="upload://r0YyHNrj7whq6t2tDCIUMHkK3lO.jpeg" alt="Gallery photo 5" loading="lazy"></div>
    </div>

    <div class="swiper-button-prev" aria-label="Previous slide"></div>
    <div class="swiper-button-next" aria-label="Next slide"></div>
    <div class="swiper-pagination" aria-label="Pagination"></div>
  </div>

  <div class="lightbox" id="lightbox" aria-hidden="true">
    <div class="lightbox__panel" role="dialog" aria-modal="true" aria-label="Expanded image">
      <button type="button" class="lightbox__close" id="lbClose" aria-label="Close">×</button>
      <img class="lightbox__img" id="lbImg" alt="">
    </div>
    <div class="lightbox__hint">Click anywhere or press Esc to close</div>
  </div>

  <!-- Swiper JS (CDN) -->
  <script src="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.js"></script>

  <script>
    const clamp = (n, min, max) => Math.max(min, Math.min(max, n));

    function readCSSNumber(varName, fallback){
      const v = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
      const n = parseFloat(v);
      return Number.isFinite(n) ? n : fallback;
    }

    const swiper = new Swiper(".my-coverflow", {
      effect: "coverflow",
      grabCursor: true,
      centeredSlides: true,
      slidesPerView: "auto",
      loop: true,
      speed: 700,
      watchSlidesProgress: true,
      slideToClickedSlide: true,

      // Responsive coverflow tuning (Bootstrap-ish)
      breakpoints: {
        0: {
          coverflowEffect: { rotate: 28, stretch: -34, depth: 260, modifier: 1.10, slideShadows: false }
        },
        576: {
          coverflowEffect: { rotate: 32, stretch: -44, depth: 340, modifier: 1.15, slideShadows: false }
        },
        768: {
          coverflowEffect: { rotate: 36, stretch: -54, depth: 420, modifier: 1.20, slideShadows: false }
        },
        992: {
          coverflowEffect: { rotate: 38, stretch: -58, depth: 460, modifier: 1.25, slideShadows: false }
        },
        1200: {
          coverflowEffect: { rotate: 40, stretch: -62, depth: 520, modifier: 1.28, slideShadows: false }
        }
      },

      pagination: { el: ".my-coverflow .swiper-pagination", clickable: true },
      navigation: { nextEl: ".my-coverflow .swiper-button-next", prevEl: ".my-coverflow .swiper-button-prev" },
      keyboard: { enabled: true }
    });

    // Pop control
    let popSlide = null;
    let popUntil = 0;

    function updateVisuals() {
      const now = performance.now();
      const activeScale = readCSSNumber("--activeScale", 1.24);
      const popExtra = readCSSNumber("--popExtra", 1.12);

      swiper.slides.forEach((slide) => {
        const img = slide.querySelector("img");
        if (!img) return;

        const isActive = slide.classList.contains("swiper-slide-active");
        const p = slide.progress || 0;
        const a = clamp(Math.abs(p), 0, 3);

        // Strong fade for non-active slides (very obvious)
        const opacity = isActive ? 1 : clamp(1 - a * 0.78, 0.04, 0.75);

        // Fixed expanded mode for active: ignore falloff scale
        let scale = isActive ? activeScale : clamp(1 - a * 0.12, 0.74, 0.98);

        // Smooth overshoot pop when slide becomes active
        if (isActive && slide === popSlide && now < popUntil) {
          scale *= popExtra;
        }

        img.style.opacity = opacity.toFixed(3);
        img.style.transform = `translateZ(0) scale(${scale.toFixed(3)})`;

        // Clear and crisp on active, more blur/desat off-center
        if (isActive) {
          img.style.filter = "blur(0px) saturate(1.10) contrast(1.03)";
        } else {
          const blur = a * 1.25;
          const sat  = clamp(1.05 - a * 0.28, 0.60, 1.05);
          const con  = clamp(1.02 - a * 0.10, 0.80, 1.02);
          img.style.filter = `blur(${blur.toFixed(2)}px) saturate(${sat.toFixed(2)}) contrast(${con.toFixed(2)})`;
        }
      });
    }

    function startPop() {
      const popMs = readCSSNumber("--popMs", 420);
      popSlide = swiper.slides[swiper.activeIndex] || null;
      popUntil = performance.now() + popMs;

      updateVisuals();
      clearTimeout(startPop._t);
      startPop._t = setTimeout(updateVisuals, popMs + 80);
    }

    swiper.on("init", () => { updateVisuals(); startPop(); });
    swiper.on("setTranslate", updateVisuals);
    swiper.on("resize", () => { swiper.update(); updateVisuals(); });
    swiper.on("slideChangeTransitionStart", startPop);

    // Some embeds won’t fire init in the order you expect; force first render:
    updateVisuals();
    startPop();

    // ===== Lightbox (click active image to expand) =====
    const coverflowEl = document.getElementById("coverflow");
    const lightbox = document.getElementById("lightbox");
    const lbImg = document.getElementById("lbImg");
    const lbClose = document.getElementById("lbClose");

    let isOpen = false;
    let lastActiveImg = null;

    function openLightboxFromActive() {
      const activeSlide = swiper.slides[swiper.activeIndex];
      const activeImg = activeSlide && activeSlide.querySelector("img");
      if (!activeImg) return;

      lastActiveImg = activeImg;

      lbImg.src = activeImg.currentSrc || activeImg.src;
      lbImg.alt = activeImg.alt || "Expanded image";
      lbImg.style.transform = "scale(2)";

      lightbox.classList.add("is-open");
      lightbox.setAttribute("aria-hidden", "false");
      isOpen = true;
    }

    function closeLightbox() {
      if (!isOpen) return;

      lightbox.classList.remove("is-open");
      lightbox.setAttribute("aria-hidden", "true");
      lbImg.removeAttribute("src");
      isOpen = false;

      if (lastActiveImg) {
        try { lastActiveImg.scrollIntoView({ block: "nearest", inline: "center" }); } catch(e) {}
      }
    }

    coverflowEl.addEventListener("click", (e) => {
      if (isOpen) return;
      const slide = e.target.closest(".swiper-slide");
      if (!slide) return;
      if (!slide.classList.contains("swiper-slide-active")) return;
      openLightboxFromActive();
    });

    lbClose.addEventListener("click", (e) => {
      e.preventDefault();
      e.stopPropagation();
      closeLightbox();
    });

    lightbox.addEventListener("click", (e) => {
      e.preventDefault();
      e.stopPropagation();
      closeLightbox();
    });

    window.addEventListener("keydown", (e) => {
      if (e.key === "Escape" && isOpen) closeLightbox();
    });
  </script>
</body>
</html>

Thank you @KBConcepts

Miles and miles of code for a simple thing.
But anyway … great job KBConcept.
Thank you

Hi @Derek - I use coverflow on a few projects, but not sure if its the right thing to be using for what you are looking for in my personal opinion.

I would opt for keeping it simple and use horizontal scroll so, I find it looks much better and the user can scroll easier and no messing - its all in Blocs ready to do.

But thats just my way and not saying it’s right ! - but good luck on what you do and please post your results.

Good luck

1 Like

I know, I was just playing in the sandbox. :upside_down_face:

2 Likes