StreamShowcase
Editorial carousel for streaming-style shelves.
Live demo
01Interactive playground
Tune the fan live β card count, spread angle and theme all bind to one instance.
Queue up. Level up.
4 Β· Title: Ship Logs Β· Last selected: noneDefault deck β 10 cards, dark theme
The flagship configuration with full sample playlists and every interaction wired in.
Queue up. Level up.
5 Β· Title: Solo to SaaSLight theme β fewer cards (count = 7)
Same component on a warm light canvas with a tighter seven-card fan.
Read up. Tune in.
Custom playlists β 4 hand-rolled cards
Bespoke playlists with custom covers; the toast below echoes the onSelect payload.
Make time. Press play.
onSelect payload here.Implementation
02<script>
import StreamShowcase from '$lib/components/StreamShowcase/StreamShowcase.svelte';
β
let active = $state(5);
function handleSelect(p) {
console.log('selected', p.slug);
}
</script>
β
<StreamShowcase bind:active onSelect={handleSelect} />StreamShowcase pairs a brush-script hero with a 10-card fan carousel. Side cards splay around a pivot below the deck; click a side card to bring it to centre, drag horizontally to spin, or use Arrow / Home / End / Enter for keyboard control. No external assets β card art is pure CSS gradients with color-mix tints.
Logic explainer
03What Does It Do? (Plain English)
StreamShowcase is a full-bleed editorial section for "now playing" / "now browsing" style hero shelves. The top half is a brush-script title ("Queue up. / Level up.") with a staggered letter entrance; the bottom half is a fan of ten cards splayed around a shared pivot below the deck, each card a colour-gradient billboard for one of your playlists. Click a side card to bring it to centre, drag the deck to rotate it, or use the arrow keys β and Enter on the centre card fires onSelect.
Think of it as an album-cover wall that's been arranged into a deck of cards held by an invisible hand below the page, ready to be flipped through.
How It Works (Pseudo-Code)
state:
active β [0, count) // which card is at centre, bindable
dragging = false
dragStartX = 0
dragStartActive = 0
derive:
cards = repeat(playlists, count) // loop the input until length === count
fanAngle(i, active, count) // angle in degrees per card
cardTransforms = map cards β translate3d + rotateZ around shared pivot
events:
on keydown β: active = max(0, active - 1)
on keydown β: active = min(count - 1, active + 1)
on keydown Home: active = 0
on keydown End: active = count - 1
on keydown Enter on active card:
onSelect?.(playlists[active % playlists.length], active)
on click side card[i]: active = i
on click centre card[active]: onSelect?.(playlists[active % length], active)
on pointerdown deck:
dragging = true
dragStartX = e.clientX
dragStartActive = active
deck.setPointerCapture(e.pointerId)
on pointermove (while dragging):
delta = e.clientX - dragStartX
spin the fan in real time using delta (CSS variable)
on pointerup:
dragging = false
snap to nearest card based on travel:
newActive = round(dragStartActive - delta / SNAP_PX)
active = clamp(newActive, 0, count - 1)The hero animation and the carousel deck are independent subcomponents β StreamShowcaseHero and StreamShowcaseCarousel β each with its own state. The wrapper just provides theming, glow effects, and prop pass-through.
The Core Concept: Fan Layout Around a Shared Pivot
A standard carousel translates each card horizontally by some multiple of the card width. A fan carousel rotates each card around a pivot point well below the deck β typically 800β1200 pixels β so cards fan out like the cards in your hand at a poker table.
visible deck
ββββββββββββββββ
β ββββββββ β
β ββββββββββ β
ββββ βββββββ β β cards splay outward
ββ β ββcentreβββ because each is
ββββ βββββββ ββ rotated around
β ββββββββββ ββ the same pivot
β ββββββββ ββ
ββββββββββββββββ
\ /
\ /
\/
pivot
(off-screen, ~1000 px below)For a card at index i with active at centre and count total cards, the angle is approximately:
angleDeg = (i - active) * fanSpread / (count - 1)where fanSpread is the total angle from leftmost to rightmost card (e.g. 60Β°). The card's transform is:
translate3d(0, 0, 0) // initial
rotate(angleDeg) at transform-origin (50%, 1000px) // rotate around pivottransform-origin: 50% 1000px is the trick. It puts the rotation centre 1000 px below the card's own top edge β invisible, but every card rotates around the same point because they all share that origin. The mathematical effect is identical to placing every card at the pivot, fanning them out, and only rendering the top portion.
Easing the rotation through easedRotation() (a small cubic that pinches near the centre) makes the centre card rotate more slowly than the outer cards, mimicking how a real hand of cards splays.
Letter-by-Letter Hero Entrance
The hero's top and bottom lines render as a row of <span> letters, each with its own transition-delay so they animate in sequence:
<h1 class="ssh-line ssh-top">
<span class="visually-hidden">Queue up.</span>
{#each letters as char, i}
<span class="ssh-letter" style="--delay: {i * 35}ms" aria-hidden="true">
{char}
</span>
{/each}
</h1>The visually-hidden span carries the canonical text for screen readers; the per-letter spans are decorative (aria-hidden="true"). On mount, a class flip triggers the CSS transition:
.ssh-letter {
opacity: 0;
transform: translateY(20px);
filter: blur(6px);
transition: opacity 600ms, transform 600ms, filter 400ms;
transition-delay: var(--delay, 0ms);
}
.ssh-hero.is-visible .ssh-letter {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}prefers-reduced-motion: reduce short-circuits the entrance β the letters render at their final state with no transition. The screen-reader text is unaffected either way; the letter-stagger is purely visual sugar.
Drag-to-Rotate with Pointer Capture
The deck is one big pointermove consumer. The drag handler maintains the current active index in a separate dragStartActive snapshot at pointerdown, so the calculation during drag is always relative to where the deck started rather than accumulating per-frame error.
on pointerdown:
dragging = true
dragStartX = e.clientX
dragStartActive = active
deck.setPointerCapture(e.pointerId)
on pointermove (dragging):
liveDelta = e.clientX - dragStartX // px since drag started
visualOffset = liveDelta / SNAP_PX // fractional cards
apply rotation = (dragStartActive - visualOffset) β CSS variable
on pointerup:
dragging = false
newActive = round(dragStartActive - liveDelta / SNAP_PX)
active = clamp(newActive, 0, count - 1)
// CSS transitions snap the deck to the new index over ~300 mssetPointerCapture keeps events flowing to the deck even when the cursor strays off the carousel β important because users tend to drag in big arcs that overshoot the visible area.
When prefers-reduced-motion: reduce is set, drag-to-rotate is disabled (the pointerdown handler short-circuits). Keyboard and click navigation still work, so the carousel remains fully usable without motion.
State Flow Diagram
ββββββββββββββββββββββββ
β IDLE β
β active = floor(n/2) β
β dragging = false β
ββββββββββββ¬ββββββββββββ
β
ββββββββββββββββββββββββΌββββββββββββββββββββββββββ
β β β
keyboard ββ click side card pointerdown deck
β β β
βΌ βΌ βΌ
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββββββ
β active Β± 1 β β active = i β β DRAGGING β
β clamped β β β β pointer captured β
ββββββββ¬ββββββββ ββββββββ¬ββββββββ β deck rotates with β
β β β cursor in real β
βββββββββββββ¬ββββββββ β time β
βΌ βββββββββββ¬ββββββββββ
ββββββββββββββββββββ β
β deck animates β pointerupβ
β to new active β βΌ
β over CSS β ββββββββββββββββββββ
β transition β β snap to nearest β
ββββββββββββββββββββ β card based on β
β β travel β
βΌ βββββββββββ¬βββββββββ
ββββββββββββββββββββ β
β Enter on centre β β
β β onSelect(...) β ββββββββββββββββββββββββ
ββββββββββββββββββββProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
playlists |
Playlist[] |
SAMPLE_PLAYLISTS (5) |
Playlist data. Looped to fill count cards. |
count |
number |
10 |
Number of cards in the fan. |
eyebrow |
string |
'Now browsing' |
Small status text above the hero. |
topLine |
string |
'Queue up.' |
Hero line 1 (brush-script). |
bottomLine |
string |
'Level up.' |
Hero line 2 (brush-script). |
active |
number |
floor(count / 2) |
Which card is at centre. Bindable for parent control. |
onSelect |
(playlist: Playlist, index: number) => void |
β | Fires when the centre card is clicked or Enter is pressed on it. |
theme |
'light' | 'dark' |
'dark' |
Background theme β sets the radial gradient and text colour. |
class |
string |
'' |
Extra classes appended to the section wrapper. |
The Playlist type:
interface Playlist {
slug: string;
title: string;
tag: string;
description: string;
cover: { from: string; to: string; accent: string };
episodeCount: number;
}Edge Cases
| Situation | Behaviour |
|---|---|
playlists.length < count |
The array is looped β a five-playlist input with count: 10 repeats each playlist twice. This is intentional and matches the editorial brief. |
count = 1 |
The fan collapses to a single card at centre. No fan splay. Keyboard nav has nothing to navigate. |
User has prefers-reduced-motion: reduce |
Hero letter stagger skips (instant final state); drag-to-rotate is disabled; click and keyboard still work; eyebrow status dot stops pulsing. |
| Drag released exactly between two cards | Math.round(dragStartActive - delta / SNAP_PX) rounds half-values to the nearest integer; ties go to the higher card by JS rounding rules. |
| Active card focused, then user Tabs forward | Focus moves to the next focusable element on the page. The carousel is role="region" with one tabindex=0 (the active card); other cards are tabindex=-1. |
Bound active set by parent to a value outside [0, count) |
Component clamps internally before applying transforms; out-of-range writes are silently ignored. |
theme="light" background interacting with light card gradients |
The card gradients are designed to work on both themes. The radial-glow accents soften on light theme to keep contrast acceptable. |
| Brush-script font not installed | A system stack falls back to 'Caveat', then 'Brush Script MT', then 'Lucida Handwriting', then generic cursive. Install @fontsource/caveat-brush for pixel-perfect rendering. |
Dependencies
- Svelte 5.x β
$state,$bindable,$derived, snippets, andbind:thisfor the deck pointer-capture target. - Zero external dependencies β no GSAP, no rAF physics library, no third-party motion library. All motion is plain CSS transitions plus a few Svelte-driven inline styles.
- No external images β card art is pure CSS gradients tinted with
color-mix(). Each playlist'scoverprop describes its colour stops.
File Structure
src/lib/components/StreamShowcase/StreamShowcase.svelte # wrapper / theming
src/lib/components/StreamShowcase/StreamShowcaseHero.svelte # brush-script title
src/lib/components/StreamShowcase/StreamShowcaseCarousel.svelte # fan carousel
src/lib/components/StreamShowcase/types.ts # Playlist + helpers
src/lib/components/StreamShowcase/playlists.ts # SAMPLE_PLAYLISTS
src/lib/components/StreamShowcase.md # this file
src/lib/components/StreamShowcase.test.ts # vitest unit tests
src/routes/streamshowcase/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
playlists | Playlist[] | sample set | Array of playlist objects rendered in the fan. |
count | number | 10 | Number of card slots in the fan (cycles through playlists). |
spread | number | 5 | Degrees of fan rotation between adjacent cards. |
theme | 'light' | 'dark' | 'dark' | Background palette for the showcase canvas. |
eyebrow | string | 'Now browsing' | Small label above the headline. |
topLine / bottomLine | string | 'Queue up.' / 'Level up.' | Two-line headline above the carousel. |
active | number | floor(count / 2) | Bindable β index of the centred card. |
onSelect | (p: Playlist) => void | undefined | Fires when the centre card is activated (Enter or click). |
class | string | '' | Pass-through class for the outer frame. |