Split-media hero
An opening section with the headline, copy and actions beside an image or a muted looping video. The media reserves its aspect ratio, stacks under the copy on phones and swaps sides with one prop.
cmp_hero_split_media_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_hero_split_media_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-a81c4d1b65c84c87dfd7197d0586c7cebfaa376955310632cac06ff01f8c149f
<script lang="ts" module>
export interface SplitMediaHeroAction {
label: string;
href: string;
}
/**
* Width and height are the file's intrinsic size. They set the frame's aspect ratio before the
* file loads, so nothing below the hero moves. Use alt: '' only when the media is decorative.
*/
export type SplitMediaHeroMedia =
| { type: 'image'; src: string; alt: string; width: number; height: number }
| {
type: 'video';
src: string;
poster: string;
alt: string;
width: number;
height: number;
};
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Headline text. */
title: string;
/** Short label above the headline. */
eyebrow?: string;
/** Supporting paragraph under the headline. */
description?: string;
/** The one filled action. */
primaryAction: SplitMediaHeroAction;
/** Optional quieter link beside the primary action. */
secondaryAction?: SplitMediaHeroAction;
/** Image or muted video beside the copy. Omit it, and mediaSlot, for a text-only hero. */
media?: SplitMediaHeroMedia;
/** Which side the media takes from the lg breakpoint. The copy always comes first in the DOM. */
mediaPosition?: 'start' | 'end';
/** Replaces the built-in media, for an illustration or a picture element with art direction. */
mediaSlot?: Snippet;
/** Short caption set under the media. */
mediaCaption?: string;
/** 1 when the hero opens the page, 2 when the page already has an h1. */
headingLevel?: 1 | 2;
/** Accessible names for the video's pause and play button. */
videoLabels?: { pause: string; play: string };
}
let {
title,
eyebrow,
description,
primaryAction,
secondaryAction,
media,
mediaPosition = 'end',
mediaSlot,
mediaCaption,
headingLevel = 1,
videoLabels = { pause: 'Pause video', play: 'Play video' }
}: Props = $props();
const uid = $props.id();
const hasMedia = $derived(Boolean(mediaSlot || media));
const start = $derived(mediaPosition === 'start');
/* Reserve the file's shape before it loads; a missing or zero size falls back to 4:3. */
const ratio = $derived(
media && media.width > 0 && media.height > 0 ? `${media.width} / ${media.height}` : '4 / 3'
);
let video = $state<HTMLVideoElement>();
let playing = $state(false);
/* The pause control only appears once script runs: without it, the video never starts. */
let ready = $state(false);
/* A refused or superseded play() leaves the label on whatever the element is actually doing. */
function play(el: HTMLVideoElement) {
el.play().catch(() => {
if (video === el) playing = !el.paused;
});
}
// Runs once per video element; a new src renders a new element (see the key block below).
$effect(() => {
const el = video;
if (!el) return;
ready = true;
playing = !el.paused;
// Safari only autoplays when the muted property itself is set, not just the attribute.
el.muted = true;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
// Autoplay is decided here rather than with the autoplay attribute, so reduced motion never
// sees a first frame of movement.
if (!reduce.matches) play(el);
// Switching reduced motion on mid-play stops the clip and returns it to its poster.
const onChange = () => {
if (!reduce.matches || el.paused) return;
el.pause();
el.load();
};
reduce.addEventListener('change', onChange);
return () => {
reduce.removeEventListener('change', onChange);
playing = false;
};
});
function toggle() {
if (!video) return;
if (video.paused) play(video);
else video.pause();
}
</script>
<section
class="hero-split-media px-4 py-16 sm:px-6 sm:py-24 lg:px-8 lg:py-32"
aria-labelledby="{uid}-title"
>
<div
class={[
'mx-auto grid items-center gap-12 lg:gap-16',
hasMedia
? start
? 'max-w-7xl lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)]'
: 'max-w-7xl lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]'
: 'max-w-7xl'
]}
>
<div class={['min-w-0 text-start', !hasMedia && 'max-w-4xl']}>
{#if eyebrow}
<p
class="hero-split-media__eyebrow hero-split-media__tracked mb-2 text-xs/none font-medium tracking-[0.06em] text-balance break-words text-[var(--_muted)] uppercase"
>
{eyebrow}
</p>
{/if}
<svelte:element
this={headingLevel === 2 ? 'h2' : 'h1'}
id="{uid}-title"
class={[
'hero-split-media__title hero-split-media__tracked text-4xl/[1.1] font-semibold tracking-[-0.022em] text-balance break-words text-[var(--_ink)] sm:text-5xl/[1.05] xl:text-6xl/[1.05]',
// Without media the headline carries the section alone, so it takes the next step up.
!hasMedia && 'lg:text-6xl/[1.05] xl:text-7xl/[1.05]'
]}
>
{title}
</svelte:element>
{#if description}
<p
class="hero-split-media__prose mt-6 max-w-xl text-base/6 text-pretty break-words text-[var(--_muted)] sm:text-lg/7"
>
{description}
</p>
{/if}
<div class="mt-8 flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<a
href={primaryAction.href}
class="hero-split-media__action hero-split-media__primary inline-flex min-h-12 max-w-full items-center justify-center rounded-lg bg-[var(--_accent)] px-6 py-3 text-center text-base/6 font-medium text-[var(--_on-accent)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)]"
>
<span class="min-w-0 [overflow-wrap:anywhere]">{primaryAction.label}</span>
</a>
{#if secondaryAction}
<a
href={secondaryAction.href}
class="hero-split-media__action hero-split-media__secondary inline-flex min-h-12 max-w-full items-center justify-center rounded-lg px-6 py-3 text-center text-base/6 font-medium text-[var(--_ink)] ring-1 ring-[var(--_control-border)] ring-inset focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] sm:ring-0"
>
<!-- The arrow is joined to the last word (U+2060) so it follows a wrapped label. -->
<span class="min-w-0 [overflow-wrap:anywhere]"
>{secondaryAction.label}⁠<svg
class="ms-2 inline-block size-4 align-[-0.1875em] rtl:-scale-x-100"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M3 8h10M9 4l4 4-4 4" />
</svg></span
>
</a>
{/if}
</div>
</div>
{#if hasMedia}
<figure class={['m-0 min-w-0', start && 'lg:order-first']}>
<div
class="hero-split-media__frame relative overflow-hidden rounded-[var(--_frame-radius)] bg-[var(--_placeholder)]"
style:aspect-ratio={!mediaSlot && media ? ratio : undefined}
>
{#if mediaSlot}
{@render mediaSlot()}
{:else if media?.type === 'video'}
{#key media.src}
<video
bind:this={video}
id="{uid}-video"
class="absolute inset-0 size-full object-cover"
src={media.src}
poster={media.poster}
width={media.width}
height={media.height}
muted
loop
playsinline
preload="metadata"
disablepictureinpicture
aria-label={media.alt || undefined}
aria-hidden={media.alt ? undefined : 'true'}
onplay={() => (playing = true)}
onpause={() => (playing = false)}
></video>
{/key}
{:else if media}
<img
class="absolute inset-0 size-full object-cover"
src={media.src}
alt={media.alt}
width={media.width}
height={media.height}
loading="eager"
fetchpriority="high"
decoding="async"
/>
{/if}
<!-- The hairline sits over the media so a pale image still has an edge. -->
<span
class="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-[var(--_hairline)] ring-inset"
aria-hidden="true"
></span>
{#if !mediaSlot && media?.type === 'video' && ready}
<button
type="button"
class="hero-split-media__control absolute end-3 bottom-3 grid size-11 place-items-center rounded-full bg-zinc-950/60 text-white ring-1 ring-white/15 backdrop-blur-md"
aria-controls="{uid}-video"
aria-label={playing ? videoLabels.pause : videoLabels.play}
onclick={toggle}
>
{#if playing}
<svg class="size-4" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect x="3.5" y="2.5" width="3" height="11" rx="1" />
<rect x="9.5" y="2.5" width="3" height="11" rx="1" />
</svg>
{:else}
<!-- Nudged a pixel towards the point so the triangle looks centred. -->
<svg
class="size-4 translate-x-px"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<path
d="M4.5 2.9v10.2a.9.9 0 0 0 1.36.77l8.3-5.1a.9.9 0 0 0 0-1.54l-8.3-5.1a.9.9 0 0 0-1.36.77Z"
/>
</svg>
{/if}
</button>
{/if}
</div>
{#if mediaCaption}
<figcaption
class="hero-split-media__prose mt-3 text-[13px]/5 text-balance break-words text-[var(--_muted)]"
>
{mediaCaption}
</figcaption>
{/if}
</figure>
{/if}
</div>
</section>
<style>
/* Public tokens: set --hero-split-media-* on this section or any ancestor to retone it. */
.hero-split-media {
--_accent: var(--hero-split-media-accent, #18181b);
--_on-accent: var(--hero-split-media-on-accent, #ffffff);
--_ink: var(--hero-split-media-ink, #18181b);
--_muted: var(--hero-split-media-muted, #52525b);
--_hairline: var(--hero-split-media-hairline, rgb(0 0 0 / 0.08));
--_frame-radius: var(--hero-split-media-frame-radius, 16px);
/* The frame's fill before the media paints, one step off the page in the ink colour. */
--_placeholder: color-mix(in oklab, var(--_ink) 4%, transparent);
/*
* The primary's hover mixes 12% of this colour into the accent. White lightens a near-black
* accent; a mid-tone accent needs black, since lightening it drops white text's contrast.
*/
--_accent-hover-mix: var(--hero-split-media-accent-hover-mix, #ffffff);
/* The outlined secondary on phones: a control boundary at 3:1, stronger than a hairline. */
--_control-border: color-mix(in oklab, var(--_ink) 50%, transparent);
}
/* The one elevation: the media frame, lit from above. */
.hero-split-media__frame {
box-shadow:
0 1px 2px rgb(0 0 0 / 0.05),
0 12px 40px rgb(0 0 0 / 0.08);
}
.hero-split-media__action,
.hero-split-media__control {
transition-property: color, background-color, box-shadow, transform, opacity;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
.hero-split-media__action:active,
.hero-split-media__control:active {
transform: scale(0.98);
transition-duration: 80ms;
}
/* Hover shifts the tone one step instead of fading the button. */
.hero-split-media__primary:hover {
background-color: color-mix(in oklab, var(--_accent) 88%, var(--_accent-hover-mix));
}
.hero-split-media__secondary:hover {
background-color: color-mix(in oklab, var(--_ink) 5%, transparent);
}
.hero-split-media__control:hover {
background-color: rgb(9 9 11 / 0.75);
}
/* White fills the gap under the accent ring, so focus shows over any image. */
.hero-split-media__control:focus-visible {
outline: 2px solid var(--_accent);
outline-offset: 2px;
box-shadow: 0 0 0 2px #ffffff;
}
/* The control fades in once script runs; it sits over the media, so nothing moves. */
@starting-style {
.hero-split-media__control {
opacity: 0;
}
}
/* Arabic and Hebrew are never letter-spaced, and CJK is not tightened. */
.hero-split-media__tracked:dir(rtl),
.hero-split-media__tracked:is(:lang(ja), :lang(zh), :lang(ko)) {
letter-spacing: 0;
}
/* Uppercase does nothing for Hebrew or Arabic, so the eyebrow takes a step up in size instead. */
.hero-split-media__eyebrow:dir(rtl) {
font-size: 0.8125rem;
}
/*
* Japanese and Chinese have no spaces: break between phrases where the browser can
* (auto-phrase), keep closing punctuation on its line (strict), and never mid-word.
* Korean spaces its words, so it keeps them whole.
*/
:is(.hero-split-media__title, .hero-split-media__prose):is(:lang(ja), :lang(zh)) {
word-break: normal;
word-break: auto-phrase;
line-break: strict;
}
/* Every Japanese or Chinese character is a full em wide, so the display size steps down to keep
each clause on one line. */
.hero-split-media__title:is(:lang(ja), :lang(zh)) {
font-size: 2rem;
}
@media (min-width: 40rem) {
.hero-split-media__title:is(:lang(ja), :lang(zh)) {
font-size: 3rem;
}
}
:is(.hero-split-media__title, .hero-split-media__prose):lang(ko) {
word-break: keep-all;
}
@media (prefers-reduced-motion: reduce) {
.hero-split-media__action:active,
.hero-split-media__control:active {
transform: none;
}
}
</style>
Usage#
On this pagePresentational: renders your headline, copy, links and media exactly as supplied. It does not host, resize, transcode or optimise media, and it ships no images; supply files sized for the column (about 1400 px wide covers a 2x screen). Both actions are ordinary links. Without JavaScript the video shows its poster and does not play.
- Suggested location
src/lib/components/hero-split-media-01- Required props
titleprimaryAction
Limitations
- Ships no media. The preview's illustrations and clip are preview-only and are not part of the export.
- One image or one video. For art direction (a different crop per breakpoint) pass a picture element through
mediaSlot; the slot then owns its own sizing and aspect ratio. - The image loads eagerly with fetchpriority="high" because a hero image is usually the page's largest paint. Below the fold, pass the image through
mediaSlotwith loading="lazy" instead. - The video is muted and has no controls beyond pause and play. It is for short ambient loops, not for anything with speech or sound; use a video player for those.
- Media with a white background may need its own dark version on a dark page; the component cannot recolour an image.
- Light appearance by default. The tokens retone it for a dark or tinted page, but no dark mode is declared or selected automatically.
Example
<script lang="ts">
import SplitMediaHero from '$lib/components/hero-split-media-01/SplitMediaHero.svelte';
</script>
<SplitMediaHero
eyebrow="Riverside Lido"
title="Fifty metres of heated water, open all year."
description="Lane swimming from 6:30 every morning and lessons for children from four."
primaryAction={{ label: 'Book a lane', href: '/book' }}
secondaryAction={{ label: 'See the timetable', href: '/timetable' }}
media={{
type: 'image',
src: '/images/pool.jpg',
alt: 'The main pool from the gallery, eight lanes with lane ropes.',
width: 1600,
height: 1200
}}
/>Split-media hero#
An opening section with the headline, copy and actions on one side and an image or a short
muted video on the other. From the lg breakpoint the copy takes five parts of the width and
the media seven; below it, the copy comes first and the media follows at full width.
Media#
Pass the file's own width and height. They set the frame's aspect ratio before anything
loads, so the page never jumps. The image is loaded eagerly with fetchpriority="high",
because a hero image is usually the largest thing on the first screen.
<SplitMediaHero
title="Fifty metres of heated water, open all year."
primaryAction={{ label: 'Book a lane', href: '/book' }}
media={{
type: 'image',
src: '/images/pool.jpg',
alt: 'The main pool from the gallery, eight lanes with lane ropes.',
width: 1600,
height: 1200
}}
/>When alt can be empty#
Write alt text that says what the image shows whenever it adds something the copy does not:
a product screen, the actual venue, a menu. Use alt: '' only when the image is atmosphere
and the headline and paragraph already say everything; screen readers then skip it.
Video#
media={{
type: 'video',
src: '/media/timeline.mp4',
poster: '/media/timeline-poster.webp',
alt: 'The timeline bar for Guest seats sliding two weeks later.',
width: 960,
height: 720
}}The clip is always muted and loops. It starts only when the visitor has not asked for reduced
motion; otherwise the poster stays until they press play. A 44 px pause and play button sits in
the frame's end corner. Translate its names with videoLabels:
videoLabels={{ pause: 'השהיית הסרטון', play: 'הפעלת הסרטון' }}Keep clips short and silent, encode them as H.264 MP4 for every browser, and make the poster work on its own.
Art direction#
For a different crop on phones, pass a picture element through mediaSlot. It renders
inside the same frame (radius, hairline, shadow), and it sets its own aspect ratio:
<SplitMediaHero
title="Swimming lessons for every age"
primaryAction={{ label: 'Find a class', href: '/lessons' }}
>
{#snippet mediaSlot()}
<picture>
<source
media="(min-width: 1024px)"
srcset="/images/pool-wide.jpg"
width="1600"
height="1000"
/>
<img
class="block aspect-square h-auto w-full object-cover lg:aspect-[8/5]"
src="/images/pool-square.jpg"
alt="The learner pool from above."
width="1000"
height="1000"
/>
</picture>
{/snippet}
</SplitMediaHero>No media#
Omit both media and mediaSlot and the hero becomes a text-only introduction, on the same
start edge as the rest of the page, with the copy held to a readable width.
Retoning for a dark page#
.my-dark-band {
--hero-split-media-ink: #fafafa;
--hero-split-media-muted: #a1a1aa;
--hero-split-media-hairline: rgb(255 255 255 / 0.1);
--hero-split-media-accent: #fafafa;
--hero-split-media-on-accent: #18181b;
}Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
title | string | Yes | None | Headline text, set at display size. |
primaryAction | SplitMediaHeroAction | Yes | None | The one filled action: { label, href }. |
eyebrow | string | No | None | Short label set small and uppercase above the headline. Omitted, no eyebrow renders. |
description | string | No | None | Supporting paragraph under the headline, held to a readable measure. |
secondaryAction | SplitMediaHeroAction | No | None | Optional quieter link beside the primary action: { label, href }. |
media | SplitMediaHeroMedia | No | None | { type: 'image', src, alt, width, height } or { type: 'video', src, poster, alt, width, height }. Width and height are the file's intrinsic size and reserve its aspect ratio. alt may be empty only when the media is decorative. Omit media and mediaSlot for a text-only hero. |
mediaPosition | 'start' | 'end' | No | 'end' | Which side the media takes from the lg breakpoint. Start and end follow the text direction, and the copy always comes first in the DOM. |
mediaSlot | Snippet | No | None | Replaces the built-in media inside the same frame, for an illustration or a picture element with art direction. The snippet sets its own size. |
mediaCaption | string | No | None | Short caption set under the media. |
headingLevel | 1 | 2 | No | 1 | 1 when the hero opens the page; 2 when the page already has an h1. |
videoLabels | { pause: string; play: string } | No | { pause: 'Pause video', play: 'Play video' } | Accessible names for the video's pause and play button. Translate them with the rest of the page. |
Customization#
On this pageChange content through props, retone the section through seven --hero-split-media-* CSS variables, and edit Tailwind classes in the source for the column split, spacing or type scale.
- Accent: set
--hero-split-media-accentand--hero-split-media-on-accenttogether, keeping on-accent at 4.5:1 or better. The accent fills the primary action and draws focus rings. - Hover: the primary's hover fill mixes 12% of
--hero-split-media-accent-hover-mixinto the accent. White (the default) lightens a near-black accent; set#000000for a mid-tone accent such as the blue and amber palettes, because lightening it lowers contrast with white text. - Text:
--hero-split-media-inksets the headline and the secondary link;--hero-split-media-mutedsets the eyebrow, paragraph and caption. Keep muted at 4.5:1 against your page. - Media frame:
--hero-split-media-frame-radiusrounds the frame (16px by default; 0 for square corners) and--hero-split-media-hairlinedraws the one-pixel edge over the media. The frame's resting fill is mixed from the ink colour, so it follows a retone. - Dark page retone: ink
#fafafa, muted#a1a1aa, hairline rgb(255 255 255 / 0.1), accent#fafafa, on-accent#18181b(all--hero-split-media-*). The black shadow under the frame disappears on dark; the hairline carries the edge. - Column split: the grid's
lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] gives the media the larger share. Change both fractions (and the mirrored pair formediaPositionstart) to 1fr for an even split. - Media: supply width and height from the file itself so the frame reserves the right space. Use object-cover crops; for a different crop on phones, pass a picture element through
mediaSlot. - Video: keep clips short, silent and under a few megabytes, with a poster that works on its own, because reduced-motion visitors see the poster unless they press play.
- Headings: set
headingLevelto 2 when the page already has an h1.
Public CSS variables
| Variable | Token |
|---|---|
--hero-split-media-accent | accent |
--hero-split-media-on-accent | onAccent |
--hero-split-media-accent-hover-mix | accentHoverMix |
--hero-split-media-ink | ink |
--hero-split-media-muted | muted |
--hero-split-media-hairline | hairline |
--hero-split-media-frame-radius | frameRadius |
Accessibility#
On this page- The section is labelled by its headline, which is an h1 by default; set
headingLevelto 2 when the page already has one. - The copy comes before the media in the DOM at every width, so reading and tab order put the headline and actions first.
mediaPositionmoves the media with CSS only. - Image alt text comes from you. Describe what the image shows when it carries meaning; pass
alt: ''only when it is decoration and the copy already says everything. - A video with alt text takes it as its accessible name (
aria-label); withalt: ''it is hidden from assistive technology. It has no audio track expectation and is always muted. - The video autoplays only when prefers-reduced-motion is not set, and stops and returns to its poster if the setting is switched on while it plays. With reduced motion, the poster stays until the visitor presses play (WCAG 2.2.2).
- The pause and play button is a 44 px native button whose accessible name swaps between
videoLabels.pause andvideoLabels.play; it names the video througharia-controls. It appears once JavaScript runs, since without it the video never moves. - Links and the video button show a two-pixel accent focus ring on :focus-visible only; over media the ring sits on a white band so it shows against any image.
- White on-accent text measures 17.7:1 on the neutral accent (
#18181b), 6.7:1 on blue (#1d4ed8) and 5.0:1 on amber (#b45309). The hover fills keep it above 4.5:1: the neutral hover lightens (about 12:1), blue and amber darken (about 7.9:1 and 6.1:1). Muted text (#52525b) measures 7.7:1 on white. Ratios use the WCAG relative-luminance formula. - The layout uses logical properties and text-start, so it mirrors under dir="rtl"; tracking resets to 0 for right-to-left and CJK text, and CJK headlines keep words whole.
- Element IDs come from
$props.id(), so two heroes on one page stay unique.
Known limitations
- Contrast ratios are computed for the shipped palettes only; re-check any changed token (4.5:1 for text, 3:1 for the focus ring).
- The video button sits over the media on a translucent dark fill; over a very dark clip its boundary is the white/15 hairline and the white icon, not the fill.
- The component cannot check that alt text is accurate or that a video is free of flashing content; both are the consumer's responsibility.
Release details#
On this page- Integration
- Presentational
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 30 September 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.