Velvoro Gallery Gallery Block - Let's templates
Unlock every premium block and template with All-Access from $20 / 3 months, cancel anytime. Get All-Access
All blocks
Free Gallery

Velvoro Gallery

A gallery block extracted from the Velvoro template, available in Tailwind CSS and Bootstrap.

0 3
Velvoro Gallery preview
velvoro.html
<!DOCTYPE html>
<html lang="en">
<head>
  <script>
    window.FontAwesomeConfig = { autoReplaceSvg: 'nest' };
  </script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Velvoro Gallery | Tailwind CSS UI Block</title>
  <meta name="description" content="A gallery block extracted from the Velvoro template, available in Tailwind CSS and Bootstrap.">
  <meta name="keywords" content="Velvoro, Tailwind CSS template, HTML website template, portfolio template, fashion photography portfolio, editorial magazine template, photo archive, lookbook website, creative studio">
  <meta name="author" content="Let's templates">
  <link rel="canonical" href="">

  <meta property="og:type" content="product">
  <meta property="og:site_name" content="Let's templates">
  <meta property="og:locale" content="en_US">
  <meta property="og:url" content="">
  <meta property="og:title" content="Velvoro | Tailwind CSS Portfolio Template">
  <meta property="og:description" content="An editorial Tailwind CSS portfolio template for fashion photographers, with a split collage hero, a look switcher and a contact sheet you can mark up.">
  <meta property="og:image" content="">
  <meta property="og:image:alt" content="Velvoro template preview">

  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:url" content="">
  <meta name="twitter:title" content="Velvoro | Tailwind CSS Portfolio Template">
  <meta name="twitter:description" content="An editorial Tailwind CSS portfolio template for fashion photographers, with a split collage hero, a look switcher and a contact sheet you can mark up.">
  <meta name="twitter:image" content="">
  <meta name="twitter:image:alt" content="Velvoro template preview">
  <script src="https://cdn.tailwindcss.com/3.4.17"></script>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500&family=Jost:wght@200;300;400;500&display=swap" rel="stylesheet">
  <script>
    tailwind.config = {
      theme: {
        extend: {
          colors: { olive: { DEFAULT: '#6f7550', deep: '#4e5338' }, rust: '#b8382c', cream: '#efe7dd', ink: '#1c1f1d', slate: '#2d3430' },
          fontFamily: { sans: ['Inter', 'sans-serif'], wide: ['Jost', 'sans-serif'] }
        }
      }
    }
  </script>
  <style>
    @view-transition { navigation: auto; }
    ::view-transition-old(root) { animation: 180ms ease-out both page-fade-out; }
    ::view-transition-new(root) { animation: 220ms ease-in both page-fade-in; }
    @keyframes page-fade-out { to { opacity: 0; } }
    @keyframes page-fade-in { from { opacity: 0; } }
    html { scroll-behavior: smooth; }
    [hidden] { display: none !important; }
    .gridlines { background-image: linear-gradient(to right, rgba(255,255,255,.22) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,.22) 1px, transparent 1px); background-size: 33.333% 33.333%; }
    .spaced { letter-spacing: .9em; margin-right: -.9em; }
    .swap-img { transition: opacity .5s ease; }
    .pick-ring { stroke-dasharray: 100; stroke-dashoffset: 101; opacity: 0; transition: stroke-dashoffset .45s ease, opacity .2s; }
    [aria-pressed="true"] .pick-ring { stroke-dashoffset: 0; opacity: 1; }
    .menu-overlay { transition: opacity .4s ease, visibility .4s; }
    .menu-overlay:not(.is-open) { opacity: 0; visibility: hidden; }
    .reveal-ready [data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity .7s ease, transform .7s ease; transition-delay: var(--reveal-delay, 0ms); }
    .reveal-ready [data-reveal].is-visible { opacity: 1; transform: none; }
    @media (prefers-reduced-motion: reduce) {
      html { scroll-behavior: auto; }
      .swap-img, .pick-ring, .menu-overlay { transition: none; }
      ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
    }
  </style>
</head>
<body class="bg-slate font-sans font-light text-cream antialiased">
<% active_page = "index" %>
<% nav = { "index" => "Home", "archive" => "Archive", "the-red-room" => "Stories", "about" => "About", "contact" => "Contact" } %>
<% nav.each_with_index do |(slug, label), index| %>
<% active = active_page == slug %>
<% end %>
<% houses = [["Maison Orrel", "1531746020798-e6953c6e8e04", "Woman with a nose ring against a dusty pink wall"], ["Castellane", "1506863530036-1efeddceb993", "Black and white portrait with freckles and short curls"], ["Ivo Brask", "1502823403499-6ccfcf4fb453", "Profile of a woman bathed in violet light"], ["Atelier Senne", "1521572267360-ee0c2909d518", "Bearded man in dramatic low light"]] %>
<% houses.each_with_index do |(name, photo, alt), index| %>
<% end %>
<% houses.each_with_index do |(name, _photo, _alt), index| %>
<% end %>
<% looks = [["01", "Rust season", "1529626455594-4ff0802cfb7e", "Red-haired model in a close portrait", "1531746020798-e6953c6e8e04", "Model resting between takes against a pink wall"], ["02", "Night study", "1524504388940-b1c1722653e1", "Model in sheer black against a dark backdrop", "1506863530036-1efeddceb993", "Black and white portrait backstage"], ["03", "Violet hour", "1502823403499-6ccfcf4fb453", "Profile of a model in violet light", "1492106087820-71f1a00d2b11", "Model with lilac hair in soft daylight"], ["04", "Low light", "1521572267360-ee0c2909d518", "Bearded model in dramatic shadow", "1488161628813-04466f872be2", "Model sitting below a round window"]] %>
<!-- block:start gallery -->
  <section class="bg-slate" data-look-switch>
    <div class="mx-auto max-w-7xl px-5 py-24 md:px-10">
      <div class="flex flex-wrap items-end justify-between gap-6">
        <div>
          <p class="spaced font-wide text-[11px] uppercase text-cream/60">Season 26</p>
          <h2 class="mt-4 font-wide text-4xl font-extralight uppercase tracking-[0.15em] md:text-5xl">Looks</h2>
        </div>
        <div class="flex rounded-full border border-cream/30 p-1" role="group" aria-label="Choose a view">
          <% [["runway", "Runway"], ["backstage", "Backstage"]].each_with_index do |(value, label), index| %>
            <button type="button" data-look-view="<%= value %>" aria-pressed="<%= index.zero? %>" class="rounded-full px-5 py-2 font-wide text-[11px] uppercase tracking-[0.25em] transition aria-pressed:bg-cream aria-pressed:text-ink"><%= label %></button>
          <% end %>
        </div>
      </div>
      <ul class="mt-12 grid gap-px bg-cream/15 sm:grid-cols-2 lg:grid-cols-4">
        <% looks.each_with_index do |(number, title, runway, runway_alt, backstage, backstage_alt), index| %>
          <li class="group bg-slate" data-reveal style="--reveal-delay: <%= index * 100 %>ms">
            <a href="the-red-room.html" class="block p-4">
              <span class="relative block aspect-[3/4] overflow-hidden">
                <img src="https://images.unsplash.com/photo-<%= runway %>?auto=format&fit=crop&w=700&q=80" alt="<%= runway_alt %>" data-look="runway" class="swap-img absolute inset-0 h-full w-full object-cover transition duration-700 group-hover:scale-105" loading="lazy">
                <img src="https://images.unsplash.com/photo-<%= backstage %>?auto=format&fit=crop&w=700&q=80" alt="<%= backstage_alt %>" data-look="backstage" class="swap-img absolute inset-0 h-full w-full object-cover opacity-0 grayscale" loading="lazy" aria-hidden="true">
              </span>
              <span class="mt-4 flex items-baseline justify-between font-wide text-xs uppercase tracking-[0.25em]"><span>Look <%= number %></span><span class="text-cream/60"><%= title %></span></span>
            </a>
          </li>
        <% end %>
      </ul>
    </div>
  </section>
  <!-- block:end gallery -->
<script>
    document.querySelectorAll('[data-overlay-toggle]').forEach((button) => {
      const overlay = document.getElementById(button.getAttribute('aria-controls'));
      const label = button.querySelector('[data-overlay-label]');
      const set = (open) => {
        button.setAttribute('aria-expanded', String(open));
        overlay.classList.toggle('is-open', open);
        if (label) label.textContent = open ? 'Close' : 'Menu';
        button.querySelector('svg, i')?.classList.toggle('rotate-45', open);
        document.body.style.overflow = open ? 'hidden' : '';
      };
      button.addEventListener('click', () => set(button.getAttribute('aria-expanded') !== 'true'));
      document.addEventListener('keydown', (event) => { if (event.key === 'Escape') set(false); });
    });

    document.querySelectorAll('[data-house-swap]').forEach((root) => {
      const images = [...root.querySelectorAll('[data-house-image]')];
      const links = [...root.querySelectorAll('[data-house]')];
      const show = (index) => {
        images.forEach((image) => {
          const on = image.dataset.houseImage === index;
          image.classList.toggle('opacity-0', !on);
          image.toggleAttribute('aria-hidden', !on);
        });
        links.forEach((link) => link.classList.toggle('border-cream', link.dataset.house === index));
        links.forEach((link) => link.classList.toggle('text-cream', link.dataset.house === index));
      };
      links.forEach((link) => {
        link.addEventListener('mouseenter', () => show(link.dataset.house));
        link.addEventListener('focus', () => show(link.dataset.house));
      });
    });

    document.querySelectorAll('[data-look-switch]').forEach((root) => {
      root.querySelectorAll('[data-look-view]').forEach((button) => {
        button.addEventListener('click', () => {
          const view = button.dataset.lookView;
          root.querySelectorAll('[data-look-view]').forEach((other) => other.setAttribute('aria-pressed', String(other === button)));
          root.querySelectorAll('[data-look]').forEach((image) => {
            const on = image.dataset.look === view;
            image.classList.toggle('opacity-0', !on);
            image.toggleAttribute('aria-hidden', !on);
          });
        });
      });
    });

    document.querySelectorAll('[data-contact-sheet]').forEach((sheet) => {
      const frames = [...sheet.querySelectorAll('[data-pick]')];
      const count = sheet.querySelector('[data-pick-count]');
      const update = () => { count.textContent = frames.filter((frame) => frame.getAttribute('aria-pressed') === 'true').length; };
      frames.forEach((frame) => frame.addEventListener('click', () => {
        frame.setAttribute('aria-pressed', String(frame.getAttribute('aria-pressed') !== 'true'));
        update();
      }));
      sheet.querySelector('[data-pick-editors]')?.addEventListener('click', () => {
        frames.forEach((frame) => frame.setAttribute('aria-pressed', frame.dataset.editor));
        update();
      });
      sheet.querySelector('[data-pick-clear]')?.addEventListener('click', () => {
        frames.forEach((frame) => frame.setAttribute('aria-pressed', 'false'));
        update();
      });
    });

    document.querySelectorAll('[data-accordion]').forEach((accordion) => {
      accordion.querySelectorAll('[data-accordion-trigger]').forEach((trigger) => {
        const panel = document.getElementById(trigger.getAttribute('aria-controls'));
        trigger.addEventListener('click', () => {
          const open = trigger.getAttribute('aria-expanded') !== 'true';
          trigger.setAttribute('aria-expanded', String(open));
          panel.hidden = !open;
          (trigger.querySelector('svg') || trigger.querySelector('i'))?.classList.toggle('rotate-45', open);
        });
      });
    });

    document.querySelectorAll('[data-sort-table]').forEach((table) => {
      const body = table.querySelector('tbody');
      table.querySelectorAll('[data-sort]').forEach((button) => {
        button.addEventListener('click', () => {
          const key = button.dataset.sort;
          const ascending = button.getAttribute('aria-sort') !== 'ascending';
          table.querySelectorAll('[data-sort]').forEach((other) => other.removeAttribute('aria-sort'));
          button.setAttribute('aria-sort', ascending ? 'ascending' : 'descending');
          [...body.rows]
            .sort((a, b) => a.dataset[key].localeCompare(b.dataset[key], undefined, { numeric: true }) * (ascending ? 1 : -1))
            .forEach((row) => body.append(row));
        });
      });
    });

    document.querySelectorAll('[data-chat-form]').forEach((form) => {
      const steps = [...form.querySelectorAll('[data-chat-step]')];
      const log = form.querySelector('[data-chat-log]');
      const status = form.querySelector('[data-form-status]');
      let current = 0;
      const show = (index) => steps.forEach((step, i) => { step.hidden = i !== index; });
      form.addEventListener('submit', (event) => {
        event.preventDefault();
        const field = steps[current].querySelector('[name]');
        if (!field.checkValidity() || !String(field.value || '').trim()) {
          status.textContent = 'Could you answer that one first?';
          field.focus();
          return;
        }
        status.textContent = '';
        const bubble = document.createElement('li');
        bubble.className = 'ml-auto max-w-[80%] rounded-2xl rounded-br-sm bg-rust px-4 py-3 text-cream';
        bubble.textContent = field.value;
        log.append(bubble);
        current += 1;
        if (current < steps.length) {
          const question = document.createElement('li');
          question.className = 'max-w-[80%] rounded-2xl rounded-bl-sm bg-cream/10 px-4 py-3';
          question.textContent = steps[current].dataset.question;
          log.append(question);
          show(current);
          steps[current].querySelector('[name]').focus();
        } else {
          show(-1);
          const done = document.createElement('li');
          done.className = 'max-w-[80%] rounded-2xl rounded-bl-sm bg-cream/10 px-4 py-3';
          done.textContent = form.dataset.success;
          log.append(done);
          status.textContent = form.dataset.success;
        }
      });
      show(0);
    });

    document.querySelectorAll('[data-contact-form]').forEach((form) => {
      form.addEventListener('submit', (event) => {
        event.preventDefault();
        const status = form.parentElement.querySelector('[data-form-status]');
        if (!form.checkValidity()) {
          status.textContent = form.dataset.error;
          form.querySelector(':invalid')?.focus();
          return;
        }
        status.textContent = form.dataset.success;
        form.reset();
      });
    });

    document.querySelectorAll('[data-current-year]').forEach((el) => { el.textContent = new Date().getFullYear(); });

    (() => {
      const items = document.querySelectorAll('[data-reveal]');
      const skip = navigator.webdriver || !('IntersectionObserver' in window) || matchMedia('(prefers-reduced-motion: reduce)').matches;
      if (!items.length || skip) return;
      document.documentElement.classList.add('reveal-ready');
      const observer = new IntersectionObserver((entries) => {
        entries.forEach((entry) => {
          if (!entry.isIntersecting) return;
          entry.target.classList.add('is-visible');
          observer.unobserve(entry.target);
        });
      }, { rootMargin: '0px 0px -10% 0px' });
      items.forEach((item) => observer.observe(item));
    })();
  </script>
</body>
</html>

From this template

Velvoro screenshot

Agency

Velvoro

Unlimited access to all products

Unlock the entire library of premium templates and blocks. Renews automatically, cancel anytime.

$20 / 3 months or $60 / year
Get Unlimited Access