The C-Well reel player

Most of the time, a reel does not need to be sitting there loading before anyone has asked for it. On C-Well, the page starts with a real poster image. It is fast, it looks intentional, and it gives the visitor one clear choice: press play when you are ready.

The player behind it is from c-wellpics.com/investment, in InvestmentReel.svelte. On the live C-Well page it plays inside its own frame; this Codebook demo opens it larger so you can actually see the behavior without squeezing a film into a reading column.

Try it below. This version closes after ten seconds so you do not have to wait through the whole reel. On C-Well, it returns to the poster when the film finishes.

Frustrated Chapter 2 — 10s

What makes it feel simple

1. The frame is there before the video is. The poster and player share the same 16:9 box. That means the page does not jump when the video starts, and the visitor is not staring at an empty black rectangle while Vimeo gets itself together:

.inv-reel-frame { position: relative; padding-top: 56.25%; }
.inv-reel-layer { position: absolute; inset: 0; width: 100%; height: 100%; }

padding-top: 56.25% is simply the math for a 16:9 frame. It holds the space open from the beginning — as long as the film really is 16:9. See point 5; that assumption is the one thing here that has since bitten us.

2. The poster never has to disappear permanently. There was an earlier version that swapped the poster and player as separate pieces. When that transition went wrong, the page could look stopped while an invisible iframe kept playing audio. Keeping the poster underneath gives the player a clean place to return to and makes cleanup predictable.

3. Wait until Vimeo says it is ready. Asking for an ended event before the player exists does nothing. Once Vimeo reports ready, subscribe to the end event; when it arrives, remove the player and show the poster again:

if (ev === 'ready') post({ method: 'addEventListener', value: 'ended' });
if (ev === 'ended' || ev === 'finish') playing = false;

4. Keep the player tied to the site, not to a random hex value. Vimeo accepts a player colour on paid plans. It comes from one named constant, so the player accent and the rest of the C-Well interface stay together:

p.set('color', PLAYER_COLOR);   // 'c6613f' == --terra

One named value is easier to trust than the same hex copied around the project.

5. A fixed frame assumes every film is the same shape. Everything above is right until you hand it a film that isn't 16:9. Joshua Network reused this pattern for a lightbox, kept the fixed ratio as aspect-ratio: 16 / 9, and a 1.991:1 cinematic cut — 426×214 — arrived. Vimeo did the only thing it could: letterboxed the film inside our box. Black bars top and bottom, the frame plainly not fitting the video. Four other films on that site are all 1.778, which is why it took a year to show up.

The fix is to stop deciding the shape and ask for it. Vimeo's oEmbed reports the source dimensions:

const r = await fetch(`https://vimeo.com/api/oembed.json?url=https://vimeo.com/${id}`);
const { width, height } = await r.json();
setAr(width / height);          // cache per id; fall back to 16/9

Then size the frame from that number rather than a literal:

.stage {
  width: min(
    100%,
    1100px,
    calc((100dvh - 2 * var(--pad)) * var(--ar, 1.7778))
  );
  aspect-ratio: var(--ar, 1.7778);
}

That third term in the min() is the part worth copying. aspect-ratio on its own only decides height from width — so on a short viewport it will happily compute a box taller than the screen and overflow instead of shrinking. A 16:9 film mostly gets away with it; a taller one runs straight off the bottom. Giving min() the width that the ratio implies for the height actually available means the frame fits both dimensions, always. The var(--pad) has to match the scrim's own padding or the maths is off by exactly that much.

For an in-page frame like C-Well's, where the poster is a real file you control and its shape is known ahead of time, the fixed padding-top is still the simpler answer. Ask Vimeo when the shape is genuinely unknown until the video opens — which is what a lightbox is.

If you use this on another site

There is one server detail worth knowing. If the site sends a Permissions-Policy header, it must allow Vimeo to autoplay after a visitor presses play. Otherwise the player can load, say it is ready, and still sit on the first frame:

Permissions-Policy: autoplay=(self "https://player.vimeo.com")

allow="autoplay" on the iframe is only half of that agreement; the page itself has to be allowed to grant it.

Full source

InvestmentReel.svelte:

<script lang="ts">
	import { srcset, SIZES } from '$lib/img';
	// The reel plays IN PLACE on the page — no lightbox — and returns to its own
	// thumbnail when the film ends, on a Svelte transition. (KC, 2026-07-26)
	import { base } from '$app/paths';
	import { scale, fade } from 'svelte/transition';
	import { cubicOut } from 'svelte/easing';
	import { prefersReducedMotion } from 'svelte/motion';
	import { INVESTMENT, reelEmbeds, REELS_LIVE } from '$lib/content';
	import { playerSrc } from '$lib/vimeo';

	// Poster frames are real files in static/media so the section paints instantly
	// and doesn't wait on Vimeo just to show a still.
	const POSTERS: Record<string, { poster: string; title: string }> = {
		'462882464': { poster: '/media/reel-462882464.jpg', title: 'COLORLESS | TROY' },
		'108753983': { poster: '/media/reel-108753983.jpg', title: 'Frustrated Chapter 2' }
	};

	// Studio list wins; the seeded reels are the fallback so /investment is never
	// left with an empty frame.
	const EMBEDS = REELS_LIVE.length
		? REELS_LIVE.map((r) => ({ id: r.id, h: r.h }))
		: reelEmbeds(INVESTMENT.reels);
	const POSTER_OF = new Map(REELS_LIVE.map((r) => [r.id, { poster: r.poster, title: r.title }]));
	// Rotate per visit, the way the dynamic page re-rolled per request.
	let index = $state(0);
	$effect(() => {
		if (EMBEDS.length > 1) index = Math.floor(Math.random() * EMBEDS.length);
	});
	const reel = $derived(EMBEDS[index]);
	const meta = $derived(reel ? (POSTER_OF.get(reel.id) ?? POSTERS[reel.id]) : undefined);

	let playing = $state(false);
	let frame = $state<HTMLIFrameElement | null>(null);

	// The poster stays MOUNTED and the player sits on top of it. An earlier pass
	// crossfaded between the two as separate branches; when the transition
	// couldn't resolve, BOTH were left in the DOM at opacity 0 — and a stranded
	// iframe keeps playing audio behind a page that looks stopped. Overlaying
	// means the player always unmounts and the thumbnail is simply revealed.
	const dur = $derived(prefersReducedMotion.current ? 0 : 420);

	// Auto-close when the film ends. Subscribing on 'ready' matters: asking for
	// 'ended' before the player exists silently never fires — the bug that makes
	// people think Vimeo doesn't report the end of playback.
	$effect(() => {
		if (!playing) return;
		const ORIGIN = 'https://player.vimeo.com';
		const post = (msg: unknown) => {
			try { frame?.contentWindow?.postMessage(JSON.stringify(msg), ORIGIN); } catch { /* frame gone */ }
		};
		const onMsg = (e: MessageEvent) => {
			if (e.origin !== ORIGIN) return;
			let d: unknown = e.data;
			if (typeof d === 'string') { try { d = JSON.parse(d); } catch { return; } }
			const ev = (d as { event?: string })?.event;
			if (ev === 'ready') post({ method: 'addEventListener', value: 'ended' });
			if (ev === 'ended' || ev === 'finish') playing = false;
		};
		const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') playing = false; };
		window.addEventListener('message', onMsg);
		window.addEventListener('keydown', onKey);
		return () => {
			window.removeEventListener('message', onMsg);
			window.removeEventListener('keydown', onKey);
		};
	});
</script>

{#if reel}
	<section class="inv-reel">
		<div class="inv-reel-frame">
			<button
				class="inv-reel-layer inv-reel-poster"
				onclick={() => (playing = true)}
				aria-label={`Play ${meta?.title ?? 'the reel'}`}
				tabindex={playing ? -1 : 0}
				aria-hidden={playing}
			>
				{#if meta}
					<img src={meta.poster.startsWith('http') ? meta.poster : `${base}${meta.poster}`} srcset={meta.poster.startsWith('http') ? undefined : srcset(meta.poster)} sizes={SIZES.hero} alt="" width="1280" height="720" />
				{/if}
				<span class="inv-reel-play" aria-hidden="true">
					<svg width="58" height="58" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="3" stroke-linejoin="round"><polygon points="8 5.5 18.5 12 8 18.5" /></svg>
				</span>
				{#if meta?.title}<span class="inv-reel-title">{meta.title}</span>{/if}
			</button>

			{#if playing}
				<!-- Sits ON the thumbnail. On exit it scales back down and fades,
				     so the reel visibly returns to the frame it came from. -->
				<div
					class="inv-reel-layer inv-reel-player"
					in:scale={{ start: 0.96, opacity: 0, duration: dur, easing: cubicOut }}
					out:scale={{ start: 0.96, opacity: 0, duration: dur, easing: cubicOut }}
				>
					<iframe
						bind:this={frame}
						src={playerSrc(reel.id, reel.h)}
						title={meta?.title ?? 'C-Well Pictures reel'}
						allow="autoplay; fullscreen; picture-in-picture"
						allowfullscreen
					></iframe>
					<button class="inv-reel-stop" onclick={() => (playing = false)} aria-label="Stop the reel" transition:fade={{ duration: dur / 2 }}>
						<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18" /><line x1="18" y1="6" x2="6" y2="18" /></svg>
					</button>
				</div>
			{/if}
		</div>
	</section>
{/if}

<style>
	.inv-reel { margin: 0 auto clamp(56px, 8vh, 96px); max-width: 960px; }
	.inv-reel-frame {
		position: relative; padding-top: 56.25%; border-radius: 16px; overflow: hidden;
		background: #0c0c0b; box-shadow: 0 24px 70px -36px rgba(12,12,11,.6);
	}
	.inv-reel-layer { position: absolute; inset: 0; width: 100%; height: 100%; }
	.inv-reel-player { z-index: 2; background: #0c0c0b; border-radius: 16px; overflow: hidden; }
	.inv-reel-layer iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }

	.inv-reel-poster { padding: 0; border: none; background: none; cursor: pointer; display: block; }
	.inv-reel-poster img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .7s var(--ease-word), filter .4s ease; }
	.inv-reel-poster::after {
		content: ''; position: absolute; inset: 0; pointer-events: none;
		background: linear-gradient(to top, rgba(12,12,11,.55) 2%, rgba(12,12,11,.1) 45%, rgba(12,12,11,.25));
	}
	.inv-reel-poster:hover img { transform: scale(1.03); }
	.inv-reel-play {
		position: absolute; inset: 0; z-index: 2; display: flex; align-items: center; justify-content: center;
		color: rgba(255,255,255,.98); padding-left: 6px;
		filter: drop-shadow(0 0 1.5px rgba(0,0,0,.9)) drop-shadow(0 3px 14px rgba(0,0,0,.5));
		transition: transform .2s ease, color .2s ease;
	}
	.inv-reel-poster:hover .inv-reel-play { transform: scale(1.1); color: var(--terra-h); }
	.inv-reel-title {
		position: absolute; left: 0; right: 0; bottom: 0; z-index: 2; padding: 24px clamp(16px, 3vw, 28px) 16px;
		font-family: var(--font-mark), sans-serif; font-size: .72rem; font-weight: 600;
		letter-spacing: .18em; text-transform: uppercase; color: rgba(250,249,245,.92); text-align: left;
	}
	/* stop control — the canonical close chip, scoped inside the frame */
	.inv-reel-stop {
		position: absolute; top: 12px; right: 12px; z-index: 3;
		width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;
		border: none; border-radius: 50%; cursor: pointer; color: #fff;
		background: rgba(20,20,19,.72); box-shadow: 0 2px 14px rgba(0,0,0,.45);
		transition: background .2s var(--ease-snap), transform .25s var(--ease-snap);
	}
	.inv-reel-stop svg { width: 18px; height: 18px; }
	.inv-reel-stop:hover { background: var(--terra); transform: rotate(90deg); }
	@media (prefers-reduced-motion: reduce) {
		.inv-reel-poster img, .inv-reel-poster:hover img, .inv-reel-play { transition: none; transform: none; }
	}
</style>

vimeo.ts — the URL builder it calls:

// Vimeo player accent. NOT an API call — it's a URL parameter on the embed, and
// Vimeo only honours it on paid plans (C-Well is `plus`).
//
// This hex must stay equal to --terra in app.css, rgb(198,97,63). It lived
// hardcoded in three components, which is three chances to drift from the token
// the rest of the site uses. One constant now, so the player accent and the
// buttons can never disagree.
export const PLAYER_COLOR = 'c6613f';

/** Player URL for a normal, user-initiated play. */
export function playerSrc(id: string, hash = '', opts: { autoplay?: boolean } = {}) {
	const p = new URLSearchParams();
	if (hash) p.set('h', hash);
	if (opts.autoplay !== false) p.set('autoplay', '1');
	p.set('title', '0');
	p.set('byline', '0');
	p.set('portrait', '0');
	p.set('badge', '0');
	p.set('pip', '0');
	// Strip Vimeo's own social chrome — the heart, watch-later clock and share
	// arrow that sat over the top-right corner of the frame (and collided with
	// our stop button). These ARE controllable per-embed; the Vimeo wordmark
	// bottom-right is NOT — that one is an account setting on vimeo.com.
	p.set('like', '0');
	p.set('watchlater', '0');
	p.set('share', '0');
	p.set('dnt', '1'); // do-not-track: no Vimeo analytics cookies on our visitors
	p.set('color', PLAYER_COLOR);
	return `https://player.vimeo.com/video/${id}?${p}`;
}

/** Muted looping preview (Vimeo "background" mode) for hover states. */
export function previewSrc(id: string, hash = '') {
	const p = new URLSearchParams();
	if (hash) p.set('h', hash);
	p.set('background', '1');
	p.set('autoplay', '1');
	p.set('loop', '1');
	p.set('muted', '1');
	p.set('dnt', '1');
	return `https://player.vimeo.com/video/${id}?${p}`;
}