Streaming — Carousel Row
A reusable cinematic content carousel for streaming UIs. Landscape poster cards with quality badges, top-rank numbers, match scores and continue-watching progress bars scroll horizontally with snap, fading hover arrows, drag-to-scroll, lazy-loaded poster art, pagination dots that track position, and an explore-all end card. Built dark-first with pure vanilla JavaScript and CSS variables, fully keyboard navigable and responsive down to mobile widths.
MCP
Code
:root {
--bg: #0b0b0f;
--surface: #15151c;
--surface-2: #1e1e27;
--ink: #f4f4f7;
--ink-2: #b6b7c3;
--muted: #83859a;
--brand: #e50914;
--accent: #ffffff;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.16);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 18px;
--shadow: 0 18px 48px rgba(0, 0, 0, 0.6);
--glow: 0 0 0 2px var(--ink), 0 24px 60px rgba(0, 0, 0, 0.7);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
img, svg { display: block; }
button { font-family: inherit; }
/* ---------- top bar ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
gap: 28px;
padding: 16px 28px;
background: linear-gradient(180deg, rgba(11, 11, 15, 0.95) 0%, rgba(11, 11, 15, 0.4) 70%, transparent 100%);
backdrop-filter: blur(6px);
}
.brand {
display: flex;
align-items: center;
gap: 9px;
font-weight: 800;
letter-spacing: 0.18em;
font-size: 15px;
color: var(--ink);
}
.brand-mark {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: var(--r-sm);
background: var(--brand);
color: #fff;
font-weight: 800;
letter-spacing: 0;
box-shadow: 0 0 22px rgba(229, 9, 20, 0.55);
}
.topnav {
display: flex;
gap: 22px;
margin-right: auto;
}
.topnav a {
color: var(--ink-2);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: color 0.18s ease;
}
.topnav a:hover, .topnav a:focus-visible { color: var(--ink); outline: none; }
.topnav a.active { color: var(--ink); font-weight: 600; }
.avatar {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: var(--r-sm);
background: var(--surface-2);
border: 1px solid var(--line);
font-size: 12px;
font-weight: 700;
color: var(--ink-2);
}
/* ---------- row ---------- */
main { padding: 24px 0 64px; }
.row { margin-top: 8px; }
.row-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
padding: 0 28px 14px;
}
.row-title {
margin: 0;
font-size: 21px;
font-weight: 700;
letter-spacing: -0.01em;
}
.row-tools {
display: flex;
align-items: center;
gap: 16px;
}
.dots {
display: flex;
gap: 6px;
}
.dot {
width: 18px;
height: 3px;
border-radius: 2px;
border: none;
padding: 0;
background: var(--line-2);
cursor: pointer;
transition: background 0.2s ease, width 0.2s ease;
}
.dot.active { background: var(--ink); width: 26px; }
.dot:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.see-all {
background: none;
border: none;
color: var(--muted);
font-size: 13px;
font-weight: 600;
cursor: pointer;
letter-spacing: 0.02em;
transition: color 0.18s ease;
}
.see-all:hover { color: var(--ink); }
/* ---------- viewport ---------- */
.viewport-wrap {
position: relative;
}
.track {
display: flex;
gap: 14px;
padding: 8px 28px 22px;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: none;
cursor: grab;
}
.track::-webkit-scrollbar { display: none; }
.track.dragging { cursor: grabbing; scroll-behavior: auto; }
.track.dragging .card { pointer-events: none; }
.track:focus-visible { outline: none; }
/* ---------- card ---------- */
.card {
position: relative;
flex: 0 0 232px;
width: 232px;
scroll-snap-align: start;
border-radius: var(--r-md);
overflow: hidden;
background: var(--surface);
border: 1px solid var(--line);
cursor: pointer;
transition: transform 0.28s cubic-bezier(.2,.7,.3,1), box-shadow 0.28s ease, z-index 0s;
}
.card:hover, .card:focus-visible {
transform: scale(1.06) translateY(-4px);
box-shadow: var(--glow);
z-index: 5;
outline: none;
}
.poster {
position: relative;
aspect-ratio: 16 / 10;
background: var(--surface-2);
}
.poster .art {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
opacity: 0;
transition: opacity 0.5s ease;
}
.poster.loaded .art { opacity: 1; }
.poster .skel {
position: absolute;
inset: 0;
background: linear-gradient(100deg, var(--surface-2) 30%, #2a2a36 50%, var(--surface-2) 70%);
background-size: 280% 100%;
animation: shimmer 1.4s linear infinite;
}
.poster.loaded .skel { display: none; }
@keyframes shimmer {
from { background-position: 180% 0; }
to { background-position: -80% 0; }
}
.poster .scrim {
position: absolute;
inset: 0;
background: linear-gradient(180deg, transparent 35%, rgba(11,11,15,0.85) 100%);
}
.badges {
position: absolute;
top: 8px;
left: 8px;
display: flex;
gap: 6px;
z-index: 2;
}
.badge {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: 5px;
background: rgba(0, 0, 0, 0.55);
border: 1px solid var(--line-2);
color: var(--ink);
backdrop-filter: blur(4px);
}
.badge.q4k { color: #ffd479; border-color: rgba(255,212,121,0.4); }
.badge.new { background: var(--brand); border-color: transparent; }
.rank {
position: absolute;
bottom: 6px;
left: 10px;
font-size: 30px;
font-weight: 800;
line-height: 1;
color: var(--ink);
text-shadow: 0 2px 10px rgba(0,0,0,0.8);
z-index: 2;
}
.meta {
padding: 10px 12px 12px;
}
.card-title {
margin: 0;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
display: flex;
align-items: center;
gap: 8px;
margin-top: 5px;
font-size: 11.5px;
color: var(--muted);
}
.match { color: #6cd57e; font-weight: 700; }
.dotsep { width: 3px; height: 3px; border-radius: 50%; background: var(--muted); }
.progress {
height: 3px;
margin-top: 9px;
border-radius: 2px;
background: var(--line-2);
overflow: hidden;
}
.progress > span {
display: block;
height: 100%;
background: var(--brand);
border-radius: 2px;
}
/* ---------- end card ---------- */
.end-card {
flex: 0 0 200px;
width: 200px;
scroll-snap-align: start;
border-radius: var(--r-md);
border: 1px dashed var(--line-2);
background: linear-gradient(160deg, var(--surface) 0%, var(--surface-2) 100%);
cursor: pointer;
display: grid;
place-items: center;
transition: border-color 0.2s ease, transform 0.25s ease;
}
.end-card:hover, .end-card:focus-visible {
border-color: var(--ink);
transform: scale(1.04);
outline: none;
}
.end-card-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.end-plus {
display: grid;
place-items: center;
width: 46px;
height: 46px;
border-radius: 50%;
border: 1px solid var(--line-2);
font-size: 22px;
color: var(--ink);
transition: background 0.2s ease;
}
.end-card:hover .end-plus { background: rgba(255,255,255,0.08); }
.end-label {
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
line-height: 1.3;
}
/* ---------- arrows ---------- */
.arrow {
position: absolute;
top: 8px;
bottom: 22px;
width: 52px;
display: grid;
place-items: center;
border: none;
z-index: 10;
cursor: pointer;
color: var(--ink);
background: linear-gradient(90deg, rgba(11,11,15,0.92), rgba(11,11,15,0));
opacity: 0;
transition: opacity 0.2s ease;
}
.arrow-right { right: 0; transform: scaleX(-1); }
.arrow-left { left: 0; }
.viewport-wrap:hover .arrow,
.arrow:focus-visible { opacity: 1; }
.arrow:focus-visible { outline: none; }
.arrow[disabled] { opacity: 0 !important; pointer-events: none; }
.arrow svg {
width: 28px;
height: 28px;
fill: none;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 0.18s ease;
}
.arrow:hover svg { transform: scale(1.25); }
/* ---------- toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 28px;
transform: translate(-50%, 24px);
background: var(--surface-2);
border: 1px solid var(--line-2);
color: var(--ink);
padding: 11px 18px;
border-radius: var(--r-md);
font-size: 13.5px;
font-weight: 500;
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 100;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ---------- responsive ---------- */
@media (max-width: 520px) {
.topbar { gap: 14px; padding: 14px 16px; }
.topnav { display: none; }
.row-head { padding: 0 16px 12px; }
.row-title { font-size: 18px; }
.see-all { display: none; }
.track { padding: 8px 16px 20px; gap: 11px; }
.card { flex-basis: 168px; width: 168px; }
.end-card { flex-basis: 150px; width: 150px; }
.arrow { display: none; }
}(function () {
"use strict";
var track = document.querySelector(".track");
var endCard = track.querySelector(".end-card");
var dotsWrap = document.querySelector(".dots");
var leftBtn = document.querySelector(".arrow-left");
var rightBtn = document.querySelector(".arrow-right");
var seeAll = document.querySelector(".see-all");
var toastEl = document.getElementById("toast");
/* ---------- data (fictional) ---------- */
var titles = [
{ t: "Halcyon Drift", yr: 2026, ep: "Limited Series", match: 98, q: "4K", g: "Sci-Fi Thriller", h: 220, s: 62, l: 46, prog: 0 },
{ t: "The Vanishing Coast", yr: 2025, ep: "Season 2", match: 95, q: "HD", g: "Mystery", h: 12, s: 70, l: 40, prog: 64 },
{ t: "Ironwood", yr: 2026, ep: "New Episode", match: 91, q: "4K", g: "Drama", h: 340, s: 30, l: 38, prog: 0, fresh: true },
{ t: "Saltwater Kings", yr: 2024, ep: "Season 4", match: 88, q: "HD", g: "Crime", h: 200, s: 24, l: 32, prog: 22 },
{ t: "Moonlit Bazaar", yr: 2026, ep: "Film", match: 93, q: "4K", g: "Adventure", h: 28, s: 55, l: 50, prog: 0 },
{ t: "Static Hours", yr: 2025, ep: "Season 1", match: 86, q: "HD", g: "Horror", h: 280, s: 40, l: 26, prog: 0 },
{ t: "Paper Lanterns", yr: 2026, ep: "Limited Series", match: 97, q: "4K", g: "Romance", h: 8, s: 60, l: 52, prog: 0, fresh: true },
{ t: "Cobalt Run", yr: 2024, ep: "Season 3", match: 84, q: "HD", g: "Action", h: 210, s: 50, l: 35, prog: 88 },
{ t: "The Long Frost", yr: 2026, ep: "New Series", match: 90, q: "4K", g: "Fantasy", h: 195, s: 35, l: 30, prog: 0 },
{ t: "Neon Harvest", yr: 2025, ep: "Season 2", match: 89, q: "HD", g: "Sci-Fi", h: 300, s: 58, l: 44, prog: 0 }
];
/* build an SVG-data-URI poster so it loads without network */
function posterURI(item) {
var hue = item.h, sat = item.s, lig = item.l;
var c1 = "hsl(" + hue + "," + sat + "%," + lig + "%)";
var c2 = "hsl(" + ((hue + 40) % 360) + "," + (sat + 8) + "%," + Math.max(12, lig - 26) + "%)";
var svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="464" height="290">' +
'<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">' +
'<stop offset="0" stop-color="' + c1 + '"/><stop offset="1" stop-color="' + c2 + '"/>' +
"</linearGradient></defs>" +
'<rect width="464" height="290" fill="url(#g)"/>' +
'<circle cx="360" cy="70" r="120" fill="rgba(255,255,255,0.10)"/>' +
'<circle cx="80" cy="240" r="90" fill="rgba(0,0,0,0.18)"/>' +
"</svg>";
return "data:image/svg+xml;charset=utf8," + encodeURIComponent(svg);
}
/* ---------- render cards ---------- */
function makeCard(item, idx) {
var card = document.createElement("article");
card.className = "card";
card.tabIndex = 0;
card.setAttribute("role", "button");
card.setAttribute("aria-label", "Play " + item.t + ", " + item.match + "% match");
var badges =
'<span class="badge ' + (item.q === "4K" ? "q4k" : "") + '">' + item.q + "</span>" +
(item.fresh ? '<span class="badge new">NEW</span>' : "");
var rank = idx < 3 ? '<div class="rank">' + (idx + 1) + "</div>" : "";
var progress = item.prog > 0
? '<div class="progress"><span style="width:' + item.prog + '%"></span></div>'
: "";
card.innerHTML =
'<div class="poster">' +
'<div class="skel"></div>' +
'<div class="art"></div>' +
'<div class="scrim"></div>' +
'<div class="badges">' + badges + "</div>" +
rank +
"</div>" +
'<div class="meta">' +
'<h3 class="card-title">' + item.t + "</h3>" +
'<div class="sub">' +
'<span class="match">' + item.match + "% match</span>" +
'<span class="dotsep"></span><span>' + item.yr + "</span>" +
'<span class="dotsep"></span><span>' + item.g + "</span>" +
"</div>" +
progress +
"</div>";
/* lazy-load the poster art via IntersectionObserver */
var poster = card.querySelector(".poster");
card.dataset.src = posterURI(item);
card.addEventListener("click", function () {
toast("▶ Playing “" + item.t + "” · " + item.ep);
});
card.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
card.click();
}
});
return card;
}
titles.forEach(function (item, i) {
track.insertBefore(makeCard(item, i), endCard);
});
/* ---------- lazy load with IntersectionObserver ---------- */
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
var card = entry.target;
var poster = card.querySelector(".poster");
var art = card.querySelector(".art");
var img = new Image();
img.onload = function () { poster.classList.add("loaded"); };
img.src = card.dataset.src;
art.style.backgroundImage = "url(" + card.dataset.src + ")";
io.unobserve(card);
});
}, { root: track, rootMargin: "200px" });
track.querySelectorAll(".card").forEach(function (c) { io.observe(c); });
/* ---------- arrows ---------- */
function pageSize() {
return Math.max(track.clientWidth * 0.85, 240);
}
function scrollByDir(dir) {
track.scrollBy({ left: dir * pageSize(), behavior: "smooth" });
}
[leftBtn, rightBtn].forEach(function (btn) {
btn.addEventListener("click", function () {
scrollByDir(parseInt(btn.dataset.dir, 10));
});
});
function updateArrows() {
var max = track.scrollWidth - track.clientWidth - 1;
leftBtn.disabled = track.scrollLeft <= 1;
rightBtn.disabled = track.scrollLeft >= max;
}
/* ---------- pagination dots ---------- */
var pageCount = 1;
function buildDots() {
pageCount = Math.max(1, Math.ceil(track.scrollWidth / track.clientWidth));
dotsWrap.innerHTML = "";
for (var i = 0; i < pageCount; i++) {
var d = document.createElement("button");
d.className = "dot" + (i === 0 ? " active" : "");
d.type = "button";
d.setAttribute("role", "tab");
d.setAttribute("aria-label", "Go to page " + (i + 1));
(function (page) {
d.addEventListener("click", function () {
track.scrollTo({ left: page * track.clientWidth, behavior: "smooth" });
});
})(i);
dotsWrap.appendChild(d);
}
}
function updateDots() {
if (!pageCount) return;
var page = Math.round(track.scrollLeft / track.clientWidth);
var dots = dotsWrap.children;
for (var i = 0; i < dots.length; i++) {
dots[i].classList.toggle("active", i === page);
}
}
/* ---------- scroll listener (rAF throttled) ---------- */
var ticking = false;
track.addEventListener("scroll", function () {
if (ticking) return;
ticking = true;
requestAnimationFrame(function () {
updateArrows();
updateDots();
ticking = false;
});
});
/* ---------- keyboard on track ---------- */
track.addEventListener("keydown", function (e) {
if (e.target !== track) return;
if (e.key === "ArrowRight") { e.preventDefault(); scrollByDir(1); }
if (e.key === "ArrowLeft") { e.preventDefault(); scrollByDir(-1); }
});
/* ---------- drag to scroll ---------- */
var isDown = false, startX = 0, startScroll = 0, moved = 0;
track.addEventListener("pointerdown", function (e) {
if (e.button !== 0) return;
isDown = true;
moved = 0;
startX = e.clientX;
startScroll = track.scrollLeft;
track.setPointerCapture(e.pointerId);
});
track.addEventListener("pointermove", function (e) {
if (!isDown) return;
var dx = e.clientX - startX;
moved = Math.abs(dx);
if (moved > 4) track.classList.add("dragging");
track.scrollLeft = startScroll - dx;
});
function endDrag(e) {
if (!isDown) return;
isDown = false;
track.classList.remove("dragging");
try { track.releasePointerCapture(e.pointerId); } catch (err) {}
/* snap to nearest card after a free drag */
if (moved > 4) {
var first = track.querySelector(".card");
if (first) {
var step = first.offsetWidth + 14;
var target = Math.round(track.scrollLeft / step) * step;
track.scrollTo({ left: target, behavior: "smooth" });
}
}
}
track.addEventListener("pointerup", endDrag);
track.addEventListener("pointercancel", endDrag);
/* suppress click after a real drag */
track.addEventListener("click", function (e) {
if (moved > 6) { e.stopPropagation(); e.preventDefault(); moved = 0; }
}, true);
/* ---------- end card + see all ---------- */
function exploreAll() { toast("Opening the full Trending collection — 142 titles"); }
endCard.addEventListener("click", exploreAll);
endCard.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); exploreAll(); }
});
seeAll.addEventListener("click", exploreAll);
/* ---------- toast helper ---------- */
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("show"); }, 2600);
}
/* ---------- init + resize ---------- */
function init() {
buildDots();
updateArrows();
updateDots();
}
init();
var resizeTimer;
window.addEventListener("resize", function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(init, 180);
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nebula — Carousel Row</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="topbar">
<div class="brand"><span class="brand-mark">N</span>NEBULA</div>
<nav class="topnav" aria-label="Primary">
<a href="#" class="active">Home</a>
<a href="#">Series</a>
<a href="#">Films</a>
<a href="#">My List</a>
</nav>
<div class="avatar" aria-hidden="true">JR</div>
</header>
<main>
<section class="row" aria-roledescription="carousel" aria-label="Trending This Week">
<div class="row-head">
<h2 class="row-title">Trending This Week</h2>
<div class="row-tools">
<div class="dots" role="tablist" aria-label="Carousel pages"></div>
<button class="see-all" type="button">See all</button>
</div>
</div>
<div class="viewport-wrap">
<button class="arrow arrow-left" type="button" aria-label="Scroll left" data-dir="-1">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>
</button>
<div class="track" tabindex="0" role="group" aria-label="Trending titles, use arrow keys to scroll">
<!-- cards injected by script.js -->
<div class="end-card" role="link" tabindex="0">
<div class="end-card-inner">
<span class="end-plus" aria-hidden="true">→</span>
<span class="end-label">Explore all<br>Trending</span>
</div>
</div>
</div>
<button class="arrow arrow-right" type="button" aria-label="Scroll right" data-dir="1">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>
</button>
</div>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Carousel Row
A drop-in content row for a streaming home screen. Each row has a title, a position-aware set of pagination dots, a “See all” shortcut, and a horizontally scrolling track of landscape poster cards. Cards carry HD/4K quality badges, a top-three rank number, a green match score, genre and year, and a red continue-watching progress bar where relevant. Poster art is generated as lightweight gradient artwork and lazy-loaded through an IntersectionObserver, with a shimmering skeleton standing in until each card scrolls into view.
Interaction is the focus. Left and right arrows fade in only on hover and disable themselves at the ends of the track. The scroll snaps to card edges, supports click-and-drag (pointer events with capture, plus snap-back and click suppression after a real drag), and responds to arrow keys when the track is focused. Pagination dots highlight the current page as you scroll and let you jump between pages, all throttled with requestAnimationFrame. Hovering a card scales and lifts it with a soft glow; activating any card or the explore-all end card fires a toast.
Everything is dark-first and token-driven: colors, radii and shadows live in :root, the layout collapses gracefully to a mobile-first single-hand view at 520px, and every interactive element is reachable and operable from the keyboard with proper ARIA roles and labels.
Illustrative UI only — fictional titles, not a real streaming service.