// Create the instances up front so the arrow keys work before the first click. No autoplay (ride is off).
document.querySelectorAll('.carousel').forEach((el) => bootstrap.Carousel.getOrCreateInstance(el));
Tailwind 4 code
Requires: Tailwind CSS 4
HTML
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#6b6258] uppercase">Full-bleed hero with captions</span>
<section class="relative overflow-hidden rounded-[22px] bg-[#1f1a14] text-white" role="region" aria-roledescription="carousel" aria-label="Featured trips" data-carousel data-loop>
<div class="flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain in-[.is-ready]:[scrollbar-width:none]" data-track>
<div class="relative h-[clamp(440px,52vw,560px)] shrink-0 basis-full snap-start snap-always after:absolute after:inset-0 after:bg-[linear-gradient(to_top,rgba(20,15,10,.88),rgba(20,15,10,.55)_50%,rgba(20,15,10,.1)_85%)]" role="group" aria-roledescription="slide" aria-label="1 of 4">
<img src="https://images.unsplash.com/photo-1493246507139-91e8fad9978e?auto=format&fit=crop&w=1600&q=70" class="absolute inset-0 size-full object-cover" width="1600" height="900" alt="Moraine Lake below snow-capped peaks at sunrise">
<div class="absolute right-[clamp(22px,8vw,96px)] bottom-[clamp(76px,8vw,84px)] left-[clamp(22px,8vw,96px)] z-[1] max-w-[540px]"><span class="text-[.74rem] font-bold tracking-[.14em] text-hero-accent uppercase">Canadian Rockies · 9 days</span><h2 class="mt-[.45rem] mb-[.6rem] font-display text-[clamp(1.75rem,3.8vw,2.9rem)] leading-[1.06] font-semibold tracking-[-.01em]">Glacier lakes at first light</h2>
<p class="max-w-[46ch] leading-[1.55] text-white/90">Paddle Moraine Lake before the tour buses arrive and sleep in two backcountry lodges on the way to Lake O'Hara.</p>
<a class="mt-[1.15rem] inline-flex items-center gap-2 rounded-full bg-white px-5 py-[.72rem] text-[.9rem] font-semibold text-[#1f1a14] transition-[background-color] duration-200 hover:bg-hero-accent focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-hero-accent motion-reduce:transition-none" href="#">View itinerary <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a></div>
</div>
<div class="relative h-[clamp(440px,52vw,560px)] shrink-0 basis-full snap-start snap-always after:absolute after:inset-0 after:bg-[linear-gradient(to_top,rgba(20,15,10,.88),rgba(20,15,10,.55)_50%,rgba(20,15,10,.1)_85%)]" role="group" aria-roledescription="slide" aria-label="2 of 4">
<img src="https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?auto=format&fit=crop&w=1600&q=70" class="absolute inset-0 size-full object-cover" width="1600" height="900" loading="lazy" alt="A wooden rowing boat on the turquoise water of Lago di Braies">
<div class="absolute right-[clamp(22px,8vw,96px)] bottom-[clamp(76px,8vw,84px)] left-[clamp(22px,8vw,96px)] z-[1] max-w-[540px]"><span class="text-[.74rem] font-bold tracking-[.14em] text-hero-accent uppercase">Dolomites · 7 days</span><h2 class="mt-[.45rem] mb-[.6rem] font-display text-[clamp(1.75rem,3.8vw,2.9rem)] leading-[1.06] font-semibold tracking-[-.01em]">Hut to hut across the Dolomites</h2>
<p class="max-w-[46ch] leading-[1.55] text-white/90">Six nights in family-run rifugi, with luggage moved ahead of you and a rest day at Lago di Braies.</p>
<a class="mt-[1.15rem] inline-flex items-center gap-2 rounded-full bg-white px-5 py-[.72rem] text-[.9rem] font-semibold text-[#1f1a14] transition-[background-color] duration-200 hover:bg-hero-accent focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-hero-accent motion-reduce:transition-none" href="#">View itinerary <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a></div>
</div>
<div class="relative h-[clamp(440px,52vw,560px)] shrink-0 basis-full snap-start snap-always after:absolute after:inset-0 after:bg-[linear-gradient(to_top,rgba(20,15,10,.88),rgba(20,15,10,.55)_50%,rgba(20,15,10,.1)_85%)]" role="group" aria-roledescription="slide" aria-label="3 of 4">
<img src="https://images.unsplash.com/photo-1454496522488-7a8e488e8606?auto=format&fit=crop&w=1600&q=70" class="absolute inset-0 size-full object-cover" width="1600" height="900" loading="lazy" alt="Snow-covered Himalayan summit under a clear sky">
<div class="absolute right-[clamp(22px,8vw,96px)] bottom-[clamp(76px,8vw,84px)] left-[clamp(22px,8vw,96px)] z-[1] max-w-[540px]"><span class="text-[.74rem] font-bold tracking-[.14em] text-hero-accent uppercase">Nepal · 14 days</span><h2 class="mt-[.45rem] mb-[.6rem] font-display text-[clamp(1.75rem,3.8vw,2.9rem)] leading-[1.06] font-semibold tracking-[-.01em]">The slow road to Base Camp</h2>
<p class="max-w-[46ch] leading-[1.55] text-white/90">An extra acclimatisation day in Namche and Dingboche, and a Sherpa guide who has walked it forty times.</p>
<a class="mt-[1.15rem] inline-flex items-center gap-2 rounded-full bg-white px-5 py-[.72rem] text-[.9rem] font-semibold text-[#1f1a14] transition-[background-color] duration-200 hover:bg-hero-accent focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-hero-accent motion-reduce:transition-none" href="#">View itinerary <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a></div>
</div>
<div class="relative h-[clamp(440px,52vw,560px)] shrink-0 basis-full snap-start snap-always after:absolute after:inset-0 after:bg-[linear-gradient(to_top,rgba(20,15,10,.88),rgba(20,15,10,.55)_50%,rgba(20,15,10,.1)_85%)]" role="group" aria-roledescription="slide" aria-label="4 of 4">
<img src="https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=1600&q=70" class="absolute inset-0 size-full object-cover" width="1600" height="900" loading="lazy" alt="Waves running up an empty sandy beach at sunset">
<div class="absolute right-[clamp(22px,8vw,96px)] bottom-[clamp(76px,8vw,84px)] left-[clamp(22px,8vw,96px)] z-[1] max-w-[540px]"><span class="text-[.74rem] font-bold tracking-[.14em] text-hero-accent uppercase">Algarve · 6 days</span><h2 class="mt-[.45rem] mb-[.6rem] font-display text-[clamp(1.75rem,3.8vw,2.9rem)] leading-[1.06] font-semibold tracking-[-.01em]">Cliff paths and empty coves</h2>
<p class="max-w-[46ch] leading-[1.55] text-white/90">Walk the Seven Hanging Valleys trail in spring, with a boat back along the cliffs on the last afternoon.</p>
<a class="mt-[1.15rem] inline-flex items-center gap-2 rounded-full bg-white px-5 py-[.72rem] text-[.9rem] font-semibold text-[#1f1a14] transition-[background-color] duration-200 hover:bg-hero-accent focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-hero-accent motion-reduce:transition-none" href="#">View itinerary <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a></div>
</div>
</div>
<button class="absolute top-1/2 left-[18px] z-[2] -mt-6 grid size-12 cursor-pointer place-items-center rounded-full bg-white/94 text-[#1f1a14] shadow-[0_8px_22px_rgba(0,0,0,.2)] transition-[transform,background-color] duration-200 hover:scale-106 hover:bg-white focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-hero-accent motion-reduce:transition-none motion-reduce:hover:scale-100 max-sm:top-auto max-sm:right-[68px] max-sm:bottom-[18px] max-sm:left-auto max-sm:mt-0 max-sm:size-[42px]" type="button" data-prev data-controls hidden aria-label="Previous slide"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg></button>
<button class="absolute top-1/2 right-[18px] z-[2] -mt-6 grid size-12 cursor-pointer place-items-center rounded-full bg-white/94 text-[#1f1a14] shadow-[0_8px_22px_rgba(0,0,0,.2)] transition-[transform,background-color] duration-200 hover:scale-106 hover:bg-white focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-hero-accent motion-reduce:transition-none motion-reduce:hover:scale-100 max-sm:top-auto max-sm:bottom-[18px] max-sm:mt-0 max-sm:size-[42px]" type="button" data-next data-controls hidden aria-label="Next slide"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></button>
<div class="absolute bottom-[26px] left-[clamp(22px,8vw,96px)] z-[2] flex gap-1 max-sm:bottom-[27px]" data-controls hidden>
<button class="h-6 w-6 cursor-pointer p-0 transition-[width] duration-250 before:block before:h-1.5 before:rounded-[3px] before:bg-white/60 before:transition-colors before:duration-250 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-hero-accent aria-[current=true]:w-10 aria-[current=true]:before:bg-white motion-reduce:transition-none motion-reduce:before:transition-none" type="button" data-dot aria-label="Show slide 1"></button>
<button class="h-6 w-6 cursor-pointer p-0 transition-[width] duration-250 before:block before:h-1.5 before:rounded-[3px] before:bg-white/60 before:transition-colors before:duration-250 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-hero-accent aria-[current=true]:w-10 aria-[current=true]:before:bg-white motion-reduce:transition-none motion-reduce:before:transition-none" type="button" data-dot aria-label="Show slide 2"></button>
<button class="h-6 w-6 cursor-pointer p-0 transition-[width] duration-250 before:block before:h-1.5 before:rounded-[3px] before:bg-white/60 before:transition-colors before:duration-250 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-hero-accent aria-[current=true]:w-10 aria-[current=true]:before:bg-white motion-reduce:transition-none motion-reduce:before:transition-none" type="button" data-dot aria-label="Show slide 3"></button>
<button class="h-6 w-6 cursor-pointer p-0 transition-[width] duration-250 before:block before:h-1.5 before:rounded-[3px] before:bg-white/60 before:transition-colors before:duration-250 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-hero-accent aria-[current=true]:w-10 aria-[current=true]:before:bg-white motion-reduce:transition-none motion-reduce:before:transition-none" type="button" data-dot aria-label="Show slide 4"></button>
</div>
</section></div>
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#6b6258] uppercase">Split panel with counter</span>
<section class="relative overflow-hidden rounded-[22px] bg-split-bg text-split-ink" role="region" aria-roledescription="carousel" aria-label="Recent projects" data-carousel data-loop>
<div class="flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain in-[.is-ready]:[scrollbar-width:none]" data-track>
<div class="grid shrink-0 basis-full snap-start snap-always md:grid-cols-[1.35fr_1fr]" role="group" aria-roledescription="slide" aria-label="1 of 3">
<img src="https://images.unsplash.com/photo-1586023492125-27b2c045efd7?auto=format&fit=crop&w=1100&q=70" class="block h-60 w-full object-cover md:h-[460px]" width="1100" height="800" alt="Living room with a mustard armchair, a brass floor lamp and a framed print">
<div class="flex flex-col justify-center px-6 pt-[26px] pb-[92px] md:px-10 md:pt-11 md:pb-[110px]"><p class="font-display text-[1.9rem] leading-none font-semibold text-split-accent md:text-[2.4rem]" aria-hidden="true">01 <span class="text-base text-split-ink">/ 03</span></p>
<p class="mt-4 text-[.74rem] font-bold tracking-[.14em] text-[#5f4f40] uppercase md:mt-[26px]">Residential · Lisbon · 2025</p><h3 class="mt-[.4rem] mb-[.7rem] font-display text-[clamp(1.6rem,2.6vw,2.1rem)] leading-[1.1] font-semibold">Harbour Loft</h3>
<p class="text-[.97rem] leading-[1.6]">A former sail store turned into a two-bedroom flat. We kept the concrete floor, added oak joinery and let one yellow chair carry the colour.</p>
<a class="mt-[1.1rem] inline-flex items-center gap-[.45rem] self-start text-[.92rem] font-semibold text-split-accent underline underline-offset-4 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-split-accent" href="#">Read the case study <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a></div>
</div>
<div class="grid shrink-0 basis-full snap-start snap-always md:grid-cols-[1.35fr_1fr]" role="group" aria-roledescription="slide" aria-label="2 of 3">
<img src="https://images.unsplash.com/photo-1524758631624-e2822e304c36?auto=format&fit=crop&w=1100&q=70" class="block h-60 w-full object-cover md:h-[460px]" width="1100" height="800" loading="lazy" alt="Bright open-plan office lounge with grey sofas and black floor lamps">
<div class="flex flex-col justify-center px-6 pt-[26px] pb-[92px] md:px-10 md:pt-11 md:pb-[110px]"><p class="font-display text-[1.9rem] leading-none font-semibold text-split-accent md:text-[2.4rem]" aria-hidden="true">02 <span class="text-base text-split-ink">/ 03</span></p>
<p class="mt-4 text-[.74rem] font-bold tracking-[.14em] text-[#5f4f40] uppercase md:mt-[26px]">Workplace · Porto · 2024</p><h3 class="mt-[.4rem] mb-[.7rem] font-display text-[clamp(1.6rem,2.6vw,2.1rem)] leading-[1.1] font-semibold">Fieldwork Studio</h3>
<p class="text-[.97rem] leading-[1.6]">An office for forty people with no assigned desks. Glass walls keep the daylight moving and the lounge doubles as the Friday talk space.</p>
<a class="mt-[1.1rem] inline-flex items-center gap-[.45rem] self-start text-[.92rem] font-semibold text-split-accent underline underline-offset-4 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-split-accent" href="#">Read the case study <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a></div>
</div>
<div class="grid shrink-0 basis-full snap-start snap-always md:grid-cols-[1.35fr_1fr]" role="group" aria-roledescription="slide" aria-label="3 of 3">
<img src="https://images.unsplash.com/photo-1519710164239-da123dc03ef4?auto=format&fit=crop&w=1100&q=70" class="block h-60 w-full object-cover md:h-[460px]" width="1100" height="800" loading="lazy" alt="Round white dining table with rattan chairs by a sheer curtain">
<div class="flex flex-col justify-center px-6 pt-[26px] pb-[92px] md:px-10 md:pt-11 md:pb-[110px]"><p class="font-display text-[1.9rem] leading-none font-semibold text-split-accent md:text-[2.4rem]" aria-hidden="true">03 <span class="text-base text-split-ink">/ 03</span></p>
<p class="mt-4 text-[.74rem] font-bold tracking-[.14em] text-[#5f4f40] uppercase md:mt-[26px]">Residential · Sintra · 2023</p><h3 class="mt-[.4rem] mb-[.7rem] font-display text-[clamp(1.6rem,2.6vw,2.1rem)] leading-[1.1] font-semibold">Casa Alba</h3>
<p class="text-[.97rem] leading-[1.6]">A weekend house painted in six shades of chalk white, with linen, rattan and a dining corner that catches the evening sun.</p>
<a class="mt-[1.1rem] inline-flex items-center gap-[.45rem] self-start text-[.92rem] font-semibold text-split-accent underline underline-offset-4 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-split-accent" href="#">Read the case study <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a></div>
</div>
</div>
<div class="absolute right-6 bottom-6 flex gap-2.5 md:right-10 md:bottom-9" data-controls hidden>
<button class="grid size-[46px] cursor-pointer place-items-center rounded-full border-[1.5px] border-split-ink text-split-ink transition-[background-color,color] duration-200 hover:bg-split-ink hover:text-split-bg focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-split-accent motion-reduce:transition-none" type="button" data-prev aria-label="Previous project"><svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg></button>
<button class="grid size-[46px] cursor-pointer place-items-center rounded-full border-[1.5px] border-split-ink text-split-ink transition-[background-color,color] duration-200 hover:bg-split-ink hover:text-split-bg focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-split-accent motion-reduce:transition-none" type="button" data-next aria-label="Next project"><svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></button>
</div>
</section></div>
// Scroll-snap carousel. The track scrolls and swipes without this script; it only reveals
// the buttons and dots, wires them up and keeps their state in sync with the scroll position.
document.querySelectorAll('[data-carousel]').forEach((root) => {
const track = root.querySelector('[data-track]');
const slides = [...track.children];
const dots = [...root.querySelectorAll('[data-dot]')];
const prev = root.querySelector('[data-prev]');
const next = root.querySelector('[data-next]');
const loop = root.hasAttribute('data-loop');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const step = () => (slides[1] ? slides[1].offsetLeft - slides[0].offsetLeft : track.clientWidth);
const current = () => Math.round(track.scrollLeft / step());
const atEnd = () => track.scrollLeft >= track.scrollWidth - track.clientWidth - 2;
const go = (i) => track.scrollTo({ left: i * step(), behavior: reduce.matches ? 'auto' : 'smooth' });
const move = (dir) => {
if (loop && dir > 0 && atEnd()) return go(0);
if (loop && dir < 0 && track.scrollLeft < 2) return go(slides.length - 1);
const perView = Math.max(1, Math.floor((track.clientWidth + step() - slides[0].offsetWidth + 2) / step()));
go(current() + dir * perView);
};
const sync = () => {
const i = current();
dots.forEach((dot, n) => dot.setAttribute('aria-current', n === i));
if (!loop && prev) prev.setAttribute('aria-disabled', track.scrollLeft < 2);
if (!loop && next) next.setAttribute('aria-disabled', atEnd());
};
prev?.addEventListener('click', () => move(-1));
next?.addEventListener('click', () => move(1));
dots.forEach((dot, n) => dot.addEventListener('click', () => go(n)));
track.addEventListener('scroll', () => requestAnimationFrame(sync), { passive: true });
root.querySelectorAll('[data-controls]').forEach((el) => { el.hidden = false; });
root.classList.add('is-ready');
sync();
});
About this set
Two hero carousels for the top of a page. Full-bleed hero with captions shows four travel slides with a photo, a dark gradient behind the caption, a heading, a short paragraph and a button, plus round arrows at the sides and pill-shaped dots. Split panel with counter puts the photo on one side and the caption on a sand-coloured panel on the other, with a large 01 / 03 counter and outlined arrow buttons, and stacks the two on phones. Use the full-bleed hero for a landing page and the split panel for case studies or portfolio work, where the text matters as much as the picture. The plain CSS and Tailwind versions are a CSS scroll-snap track, so the slides swipe and scroll with no script at all. A few lines of vanilla JavaScript reveal the arrows and dots, move one slide per click, go back to the first slide after the last, and keep the current dot marked. The Bootstrap version is the stock .carousel with its indicators, controls and captions restyled, and nothing rotates on its own.
What’s included
Two layouts: full-bleed photo with caption and split image and text panel
Scroll-snap track that swipes without JavaScript in the CSS and Tailwind versions
Bootstrap version uses the native .carousel, indicators and controls
No autoplay, and the last slide leads back to the first
Arrows move to the bottom corner on phones so they never cover the caption
Accessibility
Each carousel is a region with aria-roledescription="carousel" and a label, and each slide is a group announced as "1 of 4", "2 of 4" and so on. The arrows and dots are buttons with names such as "Next slide" and "Show slide 3", and the current dot carries aria-current. Every control shows a 3px outline on keyboard focus, and under prefers-reduced-motion the slides jump instead of scrolling smoothly and the hover transitions are switched off.
Customise it
Change --hero-accent and --hero-h on .hero for the highlight colour and slide height, and --split-bg, --split-ink and --split-accent on .split. In Tailwind the same colours are --color-hero-accent and the --color-split-* theme values, and the height is the h-[clamp()] utility on each slide.