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.
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.
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.
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}`;
}