Product-screenshot hero
An opening section with the headline and actions above a large product screenshot in a browser frame, a phone frame or none. The frame reserves the image's shape and can run into the section's bottom edge.
cmp_hero_product_screenshot_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_product_screenshot_01 version 1.0.0 with variant "indigo", 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
- Indigo accent
- Version
- 1.0.0
- Digest
Full digest
sha256-bcaaeb7cc624f9d8f4ebec29d868b0d693eee2a068ebfead90f237539978c947
<script lang="ts" module>
export interface ProductScreenshotHeroAction {
/** Link text. Say where it goes: "Start free", not "Click here". */
label: string;
href: string;
}
/**
* Width and height are the file's intrinsic size. They reserve the frame's shape before the
* file loads, so nothing below the hero moves. Alt describes what the interface shows.
*/
export interface ProductScreenshotHeroScreenshot {
src: string;
alt: string;
width: number;
height: number;
/**
* An optional crop for screens under 640 px, such as one board column instead of the whole
* board, so the interface stays legible on a phone instead of shrinking to noise.
*/
narrow?: { src: string; width: number; height: number };
/**
* An optional view for 640 to 1023 px, such as two board columns, so a tablet shows the
* interface near its real size rather than the whole desktop screen at half scale.
*/
medium?: { src: string; width: number; height: number };
}
export type ProductScreenshotHeroFrame = 'browser' | 'device' | 'none';
</script>
<script lang="ts">
interface Props {
/** Headline text, set at display size. */
title: string;
/** Short label above the headline. */
eyebrow?: string;
/** Supporting paragraph under the headline. */
description?: string;
/** The one filled action. */
primaryAction: ProductScreenshotHeroAction;
/** Optional quieter link beside the primary action. */
secondaryAction?: ProductScreenshotHeroAction;
/** The product image. Omitted, the frame holds an empty placeholder of the same shape. */
screenshot?: ProductScreenshotHeroScreenshot;
/** Decorative frame around the screenshot. */
frame?: ProductScreenshotHeroFrame;
/** Text in the browser frame's title bar, such as the page name. Nothing renders without it. */
frameTitle?: string;
/** Short visible caption above the screenshot, such as "Sample workspace". */
caption?: string;
/** From the lg breakpoint, run the browser or bare frame into the section's bottom edge. */
bleed?: boolean;
/** 1 when the hero opens the page, 2 when the page already has an h1. */
headingLevel?: 1 | 2;
}
let {
title,
eyebrow,
description,
primaryAction,
secondaryAction,
screenshot,
frame = 'browser',
frameTitle,
caption,
bleed = true,
headingLevel = 1
}: Props = $props();
const uid = $props.id();
const device = $derived(frame === 'device');
/* The phone sits beside the copy, so it never meets the bottom edge. */
const bleeds = $derived(bleed && !device);
/* A missing screenshot keeps the frame's shape: a wide window, or a phone screen. */
const placeholderRatio = $derived(device ? '9 / 19.5' : '16 / 10');
</script>
<section
class={[
'hero-product-screenshot overflow-hidden px-4 py-16 sm:px-6 sm:py-24 lg:px-8 lg:py-32',
bleeds && 'lg:pb-0'
]}
aria-labelledby="{uid}-title"
>
<div
class={[
'mx-auto max-w-6xl',
device
? 'grid gap-12 md:grid-cols-[minmax(0,1fr)_auto] md:items-center lg:gap-16'
: 'flex flex-col gap-12 lg:gap-16'
]}
>
<div class="min-w-0 text-start">
{#if eyebrow}
<p
class="hero-product-screenshot__eyebrow hero-product-screenshot__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-product-screenshot__title hero-product-screenshot__tracked max-w-[15em] text-4xl/[1.1] font-semibold tracking-[-0.022em] text-balance break-words text-[var(--_ink)] sm:text-5xl/[1.05] lg:text-6xl/[1.05]"
>
{title}
</svelte:element>
{#if description}
<p
class="hero-product-screenshot__prose mt-4 max-w-[34rem] text-base/6 text-pretty break-words text-[var(--_muted)] sm:mt-6 sm:text-lg/7"
>
{description}
</p>
{/if}
<div class="mt-8 flex flex-col gap-4 sm:flex-row sm:flex-wrap">
<a
href={primaryAction.href}
class="hero-product-screenshot__action hero-product-screenshot__primary inline-flex min-h-12 max-w-full items-center justify-center rounded-lg bg-[var(--_accent)] px-6 py-2 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-product-screenshot__action hero-product-screenshot__secondary inline-flex min-h-12 max-w-full items-center justify-center rounded-lg px-6 py-2 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="hero-product-screenshot__arrow ms-2 inline-block size-4 align-[-0.1875em]"
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>
<figure class={['m-0 min-w-0', device && 'w-72 max-w-full sm:w-80']}>
{#if caption}
<figcaption
class="hero-product-screenshot__prose mb-2 text-[13px]/5 text-balance break-words text-[var(--_muted)]"
>
{caption}
</figcaption>
{/if}
<div
class={[
'hero-product-screenshot__frame relative overflow-hidden',
frame === 'browser' && 'rounded-xl bg-[var(--_frame)]',
frame === 'device' && 'rounded-[44px] bg-[var(--_frame)] p-2',
frame === 'none' && 'rounded-xl',
bleeds && 'lg:rounded-b-none'
]}
>
{#if frame === 'browser'}
<!-- Window chrome: three quiet dots and an optional title, no invented address. -->
<div
class="grid h-10 grid-cols-[3rem_minmax(0,1fr)_3rem] items-center gap-4 px-4 shadow-[inset_0_-1px_0_var(--_hairline)]"
aria-hidden="true"
>
<span class="flex gap-2">
<span class="size-2.5 rounded-full bg-[var(--_dot)]"></span>
<span class="size-2.5 rounded-full bg-[var(--_dot)]"></span>
<span class="size-2.5 rounded-full bg-[var(--_dot)]"></span>
</span>
{#if frameTitle}
<span
class="hero-product-screenshot__frame-title truncate text-center text-xs/4 font-medium text-[var(--_muted)]"
>{frameTitle}</span
>
{/if}
</div>
{/if}
<div
class={[
'hero-product-screenshot__screen relative overflow-hidden bg-[var(--_placeholder)]',
device && 'rounded-[36px]'
]}
style:aspect-ratio={screenshot ? undefined : placeholderRatio}
>
{#if screenshot}
<picture>
{#if screenshot.narrow}
<source
media="(max-width: 639.98px)"
srcset={screenshot.narrow.src}
width={screenshot.narrow.width}
height={screenshot.narrow.height}
/>
{/if}
{#if screenshot.medium}
<source
media="(max-width: 1023.98px)"
srcset={screenshot.medium.src}
width={screenshot.medium.width}
height={screenshot.medium.height}
/>
{/if}
<img
class="block h-auto w-full"
src={screenshot.src}
alt={screenshot.alt}
width={screenshot.width}
height={screenshot.height}
loading="eager"
fetchpriority="high"
decoding="async"
/>
</picture>
{/if}
{#if device}
<!-- The screen's own edge inside the bezel, so a white screenshot still reads as a screen. -->
<span
class="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-[var(--_hairline)] ring-inset"
aria-hidden="true"
></span>
{/if}
</div>
<!-- The frame's edge, over the image so a white screenshot still has one. Under bleed it runs
a pixel past the clip, so no rule is drawn along the section's bottom edge. -->
<span
class={[
'pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-[var(--_hairline)] ring-inset',
bleeds && 'lg:-bottom-px'
]}
aria-hidden="true"
></span>
</div>
</figure>
</div>
</section>
<style>
/* Public tokens: set --hero-product-screenshot-* on this section or any ancestor to retone it. */
.hero-product-screenshot {
--_accent: var(--hero-product-screenshot-accent, #4f46e5);
--_on-accent: var(--hero-product-screenshot-on-accent, #ffffff);
--_ink: var(--hero-product-screenshot-ink, #18181b);
--_muted: var(--hero-product-screenshot-muted, #52525b);
--_hairline: var(--hero-product-screenshot-hairline, rgb(0 0 0 / 0.08));
/* The browser chrome and the phone bezel. */
--_frame: var(--hero-product-screenshot-frame, #f4f4f5);
/*
* 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-product-screenshot-accent-hover-mix, #000000);
/* Derived from ink so a retone carries them: window dots, the empty screen, the phone outline. */
--_dot: color-mix(in oklab, var(--_ink) 16%, transparent);
--_placeholder: color-mix(in oklab, var(--_ink) 4%, transparent);
--_control-border: color-mix(in oklab, var(--_ink) 50%, transparent);
}
/* The one elevation: the frame, lit from above. */
.hero-product-screenshot__frame {
box-shadow:
0 1px 2px rgb(0 0 0 / 0.05),
0 12px 40px rgb(0 0 0 / 0.08);
}
.hero-product-screenshot__action {
transition-property: color, background-color, box-shadow, transform;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
.hero-product-screenshot__action:active {
transform: scale(0.98);
transition-duration: 80ms;
}
/* Hover shifts the tone one step instead of fading the button. */
.hero-product-screenshot__primary:hover {
background-color: color-mix(in oklab, var(--_accent) 88%, var(--_accent-hover-mix));
}
.hero-product-screenshot__secondary:hover {
background-color: color-mix(in oklab, var(--_ink) 5%, transparent);
}
/* The arrow points along the reading direction. */
.hero-product-screenshot__arrow:dir(rtl) {
scale: -1 1;
}
/* Arabic and Hebrew are never letter-spaced, and CJK is not tightened. */
.hero-product-screenshot__tracked:dir(rtl),
.hero-product-screenshot__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-product-screenshot__eyebrow:dir(rtl) {
text-transform: none;
font-size: 0.8125rem;
}
.hero-product-screenshot__title:dir(rtl) {
line-height: 1.25;
}
/*
* 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-product-screenshot__title, .hero-product-screenshot__prose):is(:lang(ja), :lang(zh)) {
word-break: normal;
word-break: auto-phrase;
line-break: strict;
}
.hero-product-screenshot__title:is(:lang(ja), :lang(zh), :lang(ko)) {
line-height: 1.2;
}
:is(.hero-product-screenshot__title, .hero-product-screenshot__prose):lang(ko) {
word-break: keep-all;
}
@media (prefers-reduced-motion: reduce) {
.hero-product-screenshot__action:active {
transform: none;
}
}
</style>
Usage#
On this pagePresentational: renders your headline, copy, links and screenshot exactly as supplied. It ships no screenshot and does not capture, resize or optimise one; supply a real image of your product at about twice its displayed width (2300 px covers the browser frame on a 2x screen). The frame is decoration drawn in CSS, and both actions are ordinary links. No JavaScript is needed in the browser.
- Suggested location
src/lib/components/hero-product-screenshot-01- Required props
titleprimaryAction
Limitations
- Ships no screenshot. The preview's interfaces are original mock drawings with sample data; they are preview-only and not part of the export.
- The screenshot is a static image. It does not animate, zoom or respond to clicks; a playable demo is a different component.
- The image loads eagerly with fetchpriority="high" because a hero screenshot is usually the page's largest paint. Use another component below the fold.
- From lg the browser and bare frames run into the section's bottom edge with square lower corners, and the section's own bottom padding is removed. Whatever follows the hero sits directly against the frame; set bleed=
{false}for a frame with four rounded corners and the usual padding. - Three renditions at most: narrow under 640 px, medium under 1024 px, the full screenshot above. Without medium, a tablet shows the full screenshot at about half size; without narrow, a phone gets medium if there is one. All share one alt text.
- With no screenshot the frame shows an empty 16:10 (or phone-shaped) placeholder. It is for pages still waiting on an image, not a finished state.
- No dark screenshot swap. The component is light by default; the tokens retone it for a dark page, but you supply an image that suits that page.
- Light appearance by default. No dark mode is declared or selected automatically.
Example
<script lang="ts">
import ProductScreenshotHero from '$lib/components/hero-product-screenshot-01/ProductScreenshotHero.svelte';
</script>
<ProductScreenshotHero
eyebrow="Halcyon boards"
title="The whole quarter on one board."
description="Every card shows its owner, its due date and how far along it is."
primaryAction={{ label: 'Start free', href: '/signup' }}
secondaryAction={{ label: 'See how boards work', href: '/tour/boards' }}
frameTitle="Q4 roadmap"
screenshot={{
src: '/images/board.png',
alt: 'The Q4 roadmap board with four columns of cards, from Planned to Done.',
width: 2400,
height: 1500
}}
/>Product-screenshot hero#
An opening section for a product page: the headline, a paragraph and one or two actions, then a large screenshot of the product. The screenshot is wider than the copy, starts on the same left edge as the headline, and sits in a browser frame, a phone frame or no frame at all.
The screenshot#
Use a real screenshot of your product, exported at about twice the size it is shown at (about
2300 px wide for the browser frame, 640 px for the phone), and pass its real width and
height. They reserve the image's space before it loads.
Write alt as a description of what the interface shows: "The Q4 roadmap board with four
columns of cards, from Planned to Done", not "Screenshot of our app". Do not rely on text inside
the image to make your point; say it in the headline or paragraph too.
If your interface shows numbers, make sure they are either real or clearly sample data. A
dashboard of invented revenue figures reads as a claim. The caption prop is a good place to
say "A sample workspace".
A crop for phones#
A desktop screenshot shrunk to a 360 px screen is unreadable. Pass a narrow crop of the part
that matters, and the component serves it under 640 px. Take it from your product's own phone
layout, or crop the desktop view to one column or panel, and export it at the size it is shown:
about 330 px wide on the page, so 660 to 1000 px for a sharp 2x or 3x image, with the interface's
text at its real size. A crop that still has to shrink three times is not a fix.
<ProductScreenshotHero
title="The whole quarter on one board."
primaryAction={{ label: 'Start free', href: '/signup' }}
screenshot={{
src: '/images/board.png',
alt: 'The Q4 roadmap board: cards grouped by status, each with an owner and a due date.',
width: 2400,
height: 1500,
narrow: { src: '/images/board-column.png', width: 1080, height: 1716 }
}}
/>A view for tablets#
Between 640 and 1023 px the frame is about 600 to 980 px wide, so a full desktop screenshot shows
at roughly half size. Pass medium (for example two board columns, drawn for about 720 px) and
the component serves it there:
medium: { src: '/images/board-two-columns.png', width: 1600, height: 1200 }The narrow crop and the medium view share the wide image's alt, so write alt text that is true of both: describe
what the crop keeps ("cards grouped by status, each with an owner and a due date") rather than
everything only the wide image shows ("four columns").
Frames#
browser(default): a title bar with three quiet dots. PassframeTitlefor the page name in the bar; the component never invents an address.device: a phone with concentric corners. From the md breakpoint it sits beside the copy instead of under it. Use a portrait screenshot, about 9:19.5.none: the image with rounded corners and a hairline edge.
Bleed#
From the lg breakpoint the browser and bare frames run into the section's bottom edge: the
section drops its bottom padding, and the frame's lower corners are square, so the screenshot
reads as continuing past the fold. Put a section with its own background or a top hairline
after it, or set bleed={false} for the usual padding and four rounded corners.
Retoning for a dark page#
.dark-hero {
--hero-product-screenshot-ink: #fafafa;
--hero-product-screenshot-muted: #a1a1aa;
--hero-product-screenshot-hairline: rgb(255 255 255 / 0.1);
--hero-product-screenshot-frame: #27272a;
--hero-product-screenshot-accent: #fafafa;
--hero-product-screenshot-on-accent: #18181b;
}Pair it with a screenshot taken in your product's dark theme; the component cannot recolour an image.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
title | string | Yes | None | Headline text, set at display size. |
primaryAction | ProductScreenshotHeroAction | 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 | ProductScreenshotHeroAction | No | None | Optional quieter link beside the primary action: { label, href }. |
screenshot | ProductScreenshotHeroScreenshot | No | None | { src, alt, width, height, narrow?, medium? }. Width and height are the file's intrinsic size and reserve its shape. alt describes what the interface shows and must be true of every rendition. narrow ({ src, width, height }) is used under 640 px and medium under 1024 px; each carries its own size. Omitted, the frame holds an empty placeholder. |
frame | 'browser' | 'device' | 'none' | No | 'browser' | Decorative frame around the screenshot. 'device' draws a phone and places it beside the copy from the md breakpoint; 'none' rounds the image and gives it a hairline. |
frameTitle | string | No | None | Text centred in the browser frame's title bar, such as the page name. Without it the bar shows only its dots. Ignored by the other frames. |
caption | string | No | None | Short visible caption set above the screenshot inside the figure, such as "A sample workspace". |
bleed | boolean | No | true | From the lg breakpoint, run the browser or bare frame into the section's bottom edge. Ignored by the device frame. |
headingLevel | 1 | 2 | No | 1 | 1 when the hero opens the page; 2 when the page already has an h1. |
Customization#
On this pageChange content through props, retone the section through seven --hero-product-screenshot-* CSS variables, and edit Tailwind classes in the source for the frame radius, widths or type scale.
- Accent: set
--hero-product-screenshot-accentand--hero-product-screenshot-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-product-screenshot-accent-hover-mixinto the accent. White (the default) lightens a near-black accent; set#000000for a mid-tone accent such as the indigo and emerald palettes, because lightening it lowers contrast with white text. - Text:
--hero-product-screenshot-inksets the headline and the secondary link;--hero-product-screenshot-mutedsets the eyebrow, paragraph, caption and frame title. Keep muted at 4.5:1 against your page. - Frame:
--hero-product-screenshot-framefills the browser title bar and the phone bezel (#f4f4f5by default).--hero-product-screenshot-hairlinedraws the frame's edge, the line under the title bar and the edge over a bare image. The window dots and the empty placeholder are mixed from the ink colour, so they follow a retone. - Dark page retone: ink
#fafafa, muted#a1a1aa, hairline rgb(255 255 255 / 0.1), frame#27272a, accent#fafafa, on-accent#18181b(all--hero-product-screenshot-*). The black shadow under the frame disappears on dark; the hairline carries the edge. Supply a screenshot taken in your product's dark theme. - Screenshot: export it at about twice its displayed width and pass its real width and height. For phones, pass a narrow crop of the part that matters (one column, one panel), exported for its displayed width of about 330 px (660 to 1000 px wide for a sharp 2x or 3x image); for tablets, a medium view for about 720 px (two columns, say). Each keeps the interface's text near its real size.
- Frame shape: the browser and bare frames use
rounded-xl(12 px). The phone uses a 44 px bezel radius with 8 px of padding around a 36 px screen radius; change all three together so the corners stay concentric. - Bleed: set bleed=
{false}when the next section is also white and needs the usual space, or when the bottom of the screenshot must show a rounded edge. - Headings: set
headingLevelto 2 when the page already has an h1.
Public CSS variables
| Variable | Token |
|---|---|
--hero-product-screenshot-accent | accent |
--hero-product-screenshot-on-accent | onAccent |
--hero-product-screenshot-accent-hover-mix | accentHoverMix |
--hero-product-screenshot-ink | ink |
--hero-product-screenshot-muted | muted |
--hero-product-screenshot-hairline | hairline |
--hero-product-screenshot-frame | frame |
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 screenshot is an img inside a figure; a caption renders as that figure's figcaption, so it is associated with the image.
- Alt text comes from you and is required: describe what the interface shows, not that it is a screenshot. Anything a visitor must know also belongs in the headline or paragraph, because text inside an image cannot be resized, translated or searched.
- The browser chrome, the window dots, the frame title and every hairline are
aria-hiddendecoration. The frame title is visual only; put anything important in the copy. - The copy comes before the screenshot in the DOM at every width, so reading and tab order put the headline and actions first.
- The image has width and height attributes (and the narrow crop has its own), so its space is reserved before it loads and nothing below it moves.
- Links show a two-pixel accent focus ring on :focus-visible only, and are 48 px tall. The pressed state scales the link to 98% and is removed under prefers-reduced-motion.
- White on-accent text measures 17.7:1 on the neutral accent (
#18181b), 6.3:1 on indigo (#4f46e5) and 5.5:1 on emerald (#047857); the hover fills measure about 12:1 (the neutral fill lightens), 8.1:1 and 7.2:1. Muted text (#52525b) measures 7.7:1 on white and 7.0:1 on the#f4f4f5title bar. Ratios use the WCAG relative-luminance formula. - The layout uses logical properties and text-start, so it mirrors under dir="rtl"; the screenshot itself is never mirrored. Tracking resets to 0 for right-to-left and CJK text, and CJK headlines keep phrases 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 component cannot check that alt text is accurate or that the screenshot's own text is legible; both are the consumer's responsibility.
- The outlined secondary link's phone-width border is mixed from the ink colour at 50%, which clears 3:1 on white; re-check it on a tinted page.
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.