Feature spotlight
One capability given a full section: eyebrow, heading, detailed copy, check-marked points and up to two actions beside a large screenshot or video set in a quiet frame. Media swaps sides at lg; copy stays first.
cmp_feature_spotlight_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_feature_spotlight_01 version 1.0.0 with variant "blue", 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
- Blue accent
- Version
- 1.0.0
- Digest
Full digest
sha256-b3eeb6a032a8aca34be28a514ac1b44866b0c382f9fa192d92389a0478d51c23
<script lang="ts" module>
export interface FeatureSpotlightAction {
label: string;
href: string;
}
/**
* Width and height are the file's intrinsic size. They reserve the frame's shape before the
* image loads, and a portrait image is held to a readable height instead of filling the column.
*/
export interface FeatureSpotlightImage {
src: string;
alt: string;
width: number;
height: number;
}
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Section heading. */
title: string;
/** The detailed copy: a string renders as one paragraph, a snippet renders as written. */
body: string | Snippet;
/** A screenshot or image, or a snippet (a video, a picture element) set inside the same frame. */
media: FeatureSpotlightImage | Snippet;
/** Short supporting details, each set beside a check mark. */
points?: string[];
/** Which side the media takes from the lg breakpoint. The copy always comes first in the DOM. */
mediaPosition?: 'start' | 'end';
/** Up to two links: the first is the filled action, the second a quieter text link. */
actions?: FeatureSpotlightAction[];
/** Short label above the heading. */
eyebrow?: string;
/** 2 for a section on the page, 3 when the spotlight sits inside a larger section. */
headingLevel?: 2 | 3;
}
let {
title,
body,
media,
points = [],
mediaPosition = 'end',
actions = [],
eyebrow,
headingLevel = 2
}: Props = $props();
const uid = $props.id();
const start = $derived(mediaPosition === 'start');
const image = $derived(typeof media === 'function' ? undefined : media);
const mediaSnippet = $derived(typeof media === 'function' ? media : undefined);
const bodySnippet = $derived(typeof body === 'function' ? body : undefined);
const [primary, secondary] = $derived(actions.slice(0, 2));
/*
* A portrait image would otherwise take the full column width and run several screens tall.
* The frame's width is capped so its height stays near --_media-max-block; landscape images
* never reach the cap and fill the column.
*/
const frameWidth = $derived(
image && image.width > 0 && image.height > 0
? `min(100%, calc(var(--_media-max-block) * ${image.width} / ${image.height} + 1rem))`
: undefined
);
</script>
<section
class="feature-spotlight 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 max-w-7xl items-center gap-12 lg:gap-16',
start
? 'lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)]'
: 'lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]'
]}
>
<div class="max-w-2xl min-w-0 text-start">
{#if eyebrow}
<p
class="feature-spotlight__eyebrow feature-spotlight__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 === 3 ? 'h3' : 'h2'}
id="{uid}-title"
class="feature-spotlight__title feature-spotlight__tracked text-3xl/[1.2] font-semibold tracking-[-0.02em] text-balance break-words text-[var(--_ink)] sm:text-4xl/[1.15]"
>
{title}
</svelte:element>
{#if bodySnippet}
<div
class="feature-spotlight__prose feature-spotlight__body mt-4 max-w-xl text-base/6 text-pretty break-words text-[var(--_muted)] sm:text-lg/7"
>
{@render bodySnippet()}
</div>
{:else if body}
<p
class="feature-spotlight__prose mt-4 max-w-xl text-base/6 text-pretty break-words text-[var(--_muted)] sm:text-lg/7"
>
{body}
</p>
{/if}
{#if points.length > 0}
<ul class="mt-8 grid max-w-xl gap-4" role="list">
{#each points as point, i (i)}
<li class="flex gap-4 text-base/6 text-[var(--_ink)]">
<!-- One line tall, so the mark stays level with the first line of a wrapped point. -->
<span class="flex h-lh shrink-0 items-center" aria-hidden="true">
<svg
class="size-5 text-[var(--_accent)]"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4.5 10.5 8 14l7.5-8" />
</svg>
</span>
<span class="feature-spotlight__prose min-w-0 text-pretty break-words">{point}</span>
</li>
{/each}
</ul>
{/if}
{#if primary}
<div class="mt-8 flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-center sm:gap-x-8">
<a
href={primary.href}
class="feature-spotlight__action feature-spotlight__primary inline-flex min-h-11 max-w-full items-center justify-center rounded-lg bg-[var(--_accent)] px-4 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)] sm:px-6"
>
<span class="min-w-0 text-balance [overflow-wrap:anywhere]">{primary.label}</span>
</a>
{#if secondary}
<a
href={secondary.href}
class="feature-spotlight__action feature-spotlight__secondary inline-flex min-h-11 max-w-full items-center self-start rounded-md text-base/6 font-medium text-[var(--_ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] sm:self-auto"
>
<!-- The arrow is joined to the last word (U+2060) so it follows a wrapped label. -->
<span class="min-w-0 text-balance [overflow-wrap:anywhere]"
>{secondary.label}⁠<svg
class="feature-spotlight__arrow 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>
{/if}
</div>
<figure class={['m-0 min-w-0', start && 'lg:order-first']}>
<!-- The stage: an 8px bezel of quiet fill around the media, and the section's one elevation. -->
<div
class="feature-spotlight__stage mx-auto rounded-2xl bg-[var(--_surface)] p-2 ring-1 ring-[var(--_hairline)] ring-inset"
style:max-inline-size={frameWidth}
>
<div class="relative overflow-hidden rounded-lg bg-[var(--_placeholder)]">
{#if mediaSnippet}
{@render mediaSnippet()}
{:else if image}
<img
class="block h-auto w-full"
src={image.src}
alt={image.alt}
width={image.width}
height={image.height}
loading="lazy"
decoding="async"
/>
{/if}
<!-- The hairline sits over the media so a pale screenshot 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>
</div>
</div>
</figure>
</div>
</section>
<style>
/* Public tokens: set --feature-spotlight-* on this section or any ancestor to retone it. */
.feature-spotlight {
--_accent: var(--feature-spotlight-accent, #1d4ed8);
--_on-accent: var(--feature-spotlight-on-accent, #ffffff);
--_ink: var(--feature-spotlight-ink, #18181b);
--_muted: var(--feature-spotlight-muted, #52525b);
--_hairline: var(--feature-spotlight-hairline, rgb(0 0 0 / 0.08));
/* The stage around the media: a translucent step off the page, so it follows a tinted page. */
--_surface: var(--feature-spotlight-surface, rgb(24 24 27 / 0.03));
/*
* 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(--feature-spotlight-accent-hover-mix, #000000);
/* The tallest a portrait image is allowed to run before its frame narrows instead. */
--_media-max-block: 36rem;
/* The frame's fill before the media paints. */
--_placeholder: color-mix(in oklab, var(--_ink) 4%, transparent);
}
/* The one elevation: the media stage, lit from above. */
.feature-spotlight__stage {
box-shadow:
0 1px 2px rgb(0 0 0 / 0.05),
0 12px 40px rgb(0 0 0 / 0.08);
}
/* A snippet's paragraphs and links follow the string body's rhythm. */
.feature-spotlight__body :global(p + p) {
margin-top: 1rem;
}
.feature-spotlight__body :global(a) {
color: var(--_ink);
text-decoration-line: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
}
.feature-spotlight__action,
.feature-spotlight__arrow {
transition-property: color, background-color, transform;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
.feature-spotlight__action:active {
transform: scale(0.98);
transition-duration: 80ms;
}
/* Hover shifts the fill one step instead of fading the button. */
.feature-spotlight__primary:hover {
background-color: color-mix(in oklab, var(--_accent) 88%, var(--_accent-hover-mix));
}
/* The text link underlines and its arrow steps forward, in the reading direction. */
.feature-spotlight__secondary:hover {
text-decoration-line: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.25em;
}
/* The arrow's step is movement, so it only exists when motion is allowed. It uses transform,
which composes with the scale that mirrors the arrow in right-to-left text. */
@media (prefers-reduced-motion: no-preference) {
.feature-spotlight__secondary:hover .feature-spotlight__arrow {
transform: translateX(2px);
}
.feature-spotlight__secondary:dir(rtl):hover .feature-spotlight__arrow {
transform: translateX(-2px);
}
}
/* Arabic and Hebrew are never letter-spaced, and CJK is not tightened. */
.feature-spotlight__tracked:dir(rtl),
.feature-spotlight__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. */
.feature-spotlight__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(.feature-spotlight__title, .feature-spotlight__prose):is(:lang(ja), :lang(zh)) {
word-break: normal;
word-break: auto-phrase;
line-break: strict;
}
:is(.feature-spotlight__title, .feature-spotlight__prose):lang(ko) {
word-break: keep-all;
}
@media (prefers-reduced-motion: reduce) {
.feature-spotlight__action:active {
transform: none;
}
}
</style>
Usage#
On this pagePresentational: renders your heading, copy, points, links and media exactly as supplied. It ships no images and no video player; pass a video element through the media snippet with its own controls and captions. The image loads lazily, since a spotlight usually sits below the fold. Actions are ordinary links, and only the first two render.
- Suggested location
src/lib/components/feature-spotlight-01
Limitations
- Ships no media. The preview's screenshots, illustration and clip are preview-only and are not part of the export.
- No video player. A video goes through the media snippet with native controls; it must not autoplay with sound, and a clip with speech needs a captions track (see
usage.md). - A media snippet sets its own size and aspect ratio; only the built-in image reserves its shape from width and height.
- Points are plain strings. For a lead-in term or links inside a point, edit the list markup in the source.
- At most two actions: the first is filled, the second is a text link. Further entries are ignored.
- 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 FeatureSpotlight from '$lib/components/feature-spotlight-01/FeatureSpotlight.svelte';
</script>
<FeatureSpotlight
eyebrow="Emergency appointments"
title="Toothache at 7 am? We keep four slots free every weekday."
body="Ring before 10 am and we will hold the next open slot while you travel in."
points={['Pain relief and a diagnosis at the first visit', 'X-rays on site, with no referral needed']}
actions={[
{ label: 'Call 020 7946 0321', href: 'tel:+442079460321' },
{ label: 'What an emergency visit costs', href: '/fees#emergency' }
]}
media={{
src: '/images/emergency-slots.png',
alt: 'Today\'s emergency slots: 8:30, 12:30, 13:30 and 17:15 are open.',
width: 1200,
height: 900
}}
mediaPosition="start"
/>Feature spotlight#
One capability, given a whole section. The copy sits in the narrower column: an eyebrow, the heading, a paragraph of detail, a short list of check-marked points and up to two actions. The media takes the wider column inside a quiet frame, an 8 px bezel of translucent fill with a hairline edge and the section's one shadow. Below the lg breakpoint everything stacks, copy first.
Media#
Pass an image as { src, alt, width, height }, using the file's own pixel size. The browser
reserves the frame's shape from those numbers before the file arrives, so nothing below the
section moves as it loads. The image is lazy-loaded; if the spotlight sits at the top of a page,
edit loading="lazy" to "eager" in the source.
A portrait image (taller than it is wide) would otherwise fill the column and run several
screens tall. The frame narrows instead, so the image stays near 36rem tall and sits centred in
its column. Change --_media-max-block in the source to allow more.
Anything else goes through the media snippet, rendered inside the same frame. The snippet sets
its own size.
A different crop on phones#
A screenshot of a whole screen shrinks to a third of its size on a phone, and its labels stop
being readable. Pass a picture element through the media snippet with a close-up for small
screens and the full image from the lg breakpoint:
{#snippet slots()}
<picture>
<source media="(min-width: 1024px)" srcset="/images/slots.png" width="1200" height="900" />
<img
class="block h-auto w-full"
src="/images/slots-phone.png"
alt="Today's emergency slots: 8:30, 12:30, 13:30 and 17:15 are open."
width="640"
height="740"
loading="lazy"
/>
</picture>
{/snippet}Give both sources their own width and height so the browser reserves the right shape at each size.
Video#
The component has no video player and never plays anything itself. Pass a video element with native controls:
{#snippet clip()}
<video
class="block h-auto w-full"
src="/video/timeline.mp4"
poster="/video/timeline-poster.webp"
width="1440"
height="1080"
controls
playsinline
preload="metadata"
aria-label="The Guest seats bar slides two weeks later, then back."
>
<track kind="captions" src="/video/timeline.en.vtt" srclang="en" label="English" default />
</video>
{/snippet}
<FeatureSpotlight title="Drag a date. Everything linked to it follows." body="…" media={clip} />- Never autoplay a clip with sound. A silent ambient loop may autoplay only when
prefers-reduced-motionis not set, and it still needs a way to pause (WCAG 2.2.2). - A clip with speech needs a captions track. A silent clip needs none, but say so in its name.
- Give the poster the same pixel size as the video so the frame does not jump when it loads.
Body#
A string renders as one paragraph. For two paragraphs, a link or emphasis, pass a snippet; its paragraphs are spaced 16 px apart and its links are underlined in the ink colour.
{#snippet details()}
<p>Move a bar on the timeline and the cards behind it update.</p>
<p>
Dependencies come with the Team plan. <a href="/guides/timelines">Read how linking works</a>.
</p>
{/snippet}Actions#
The first action is the filled button and the second a text link with an arrow. Anything after the second is ignored: a spotlight asks for one decision. On phones the button spans the column and the link sits under it.
Alternating sections#
Several spotlights stacked with mediaPosition alternating make a product tour. Put them under
one section heading and set headingLevel={3} on each. Each spotlight keeps its own section
padding; remove the top padding from all but the first if the stack feels loose.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
title | string | Yes | None | Section heading, set at section-title size. |
body | string | Snippet | Yes | None | The detailed copy. A string renders as one paragraph held to a readable measure; a snippet renders as written, with its paragraphs spaced and its links underlined. |
media | FeatureSpotlightImage | Snippet | Yes | None | { src, alt, width, height } for an image, or a snippet (a video with controls, a picture element) set inside the same frame. Width and height are the file's intrinsic size and reserve its shape; alt may be empty only when the image is decorative. |
points | string[] | No | [] | Short supporting details, each beside a check mark in the accent colour. Empty or omitted, no list renders. |
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. |
actions | FeatureSpotlightAction[] | No | [] | Up to two links, { label, href }: the first is the filled action, the second a text link with an arrow. Further entries are ignored. |
eyebrow | string | No | None | Short uppercase label above the heading. Omitted, no eyebrow renders. |
headingLevel | 2 | 3 | No | 2 | 2 for a section on the page; 3 when the spotlight sits inside a larger section with its own h2. |
Customization#
On this pageChange content through props, retone the section through seven --feature-spotlight-* CSS variables, and edit Tailwind classes in the source for the column split, spacing or type scale.
- Accent: set
--feature-spotlight-accentand--feature-spotlight-on-accenttogether, keeping on-accent at 4.5:1 or better. The accent fills the primary action, colours the check marks and draws focus rings. - Hover: the primary's hover fill mixes 12% of
--feature-spotlight-accent-hover-mixinto the accent. White (the default) lightens a near-black accent; set#000000for a mid-tone accent such as the blue palette, because lightening it lowers contrast with white text. - Text:
--feature-spotlight-inksets the heading, points and the text link;--feature-spotlight-mutedsets the eyebrow and body. Keep muted at 4.5:1 against your page. - Media frame:
--feature-spotlight-surfacefills the 8 px bezel around the media and--feature-spotlight-hairlinedraws its edge and the edge over the media. Both are translucent by default, so they follow a tinted page. - Dark page retone: ink
#fafafa, muted#a1a1aa, hairline rgb(255 255 255 / 0.1), surface rgb(255 255 255 / 0.04), accent#fafafa, on-accent#18181b(all--feature-spotlight-*). 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. - Portrait media: --_media-max-block (36rem) is the height a portrait image may reach before its frame narrows. Raise it for tall phone screenshots.
- Phone crop: a
full-screenscreenshot shrinks to a third of its size on phones. Pass a picture element through the media snippet with a close-up for small screens and the full image from 1024px; give each source its own width and height. - Video: pass a video element through the media snippet with class="block
h-autow-full", width, height, a poster, controls and no autoplay. Add <track kind="captions"> when the clip has speech. - Alternating sections: render several spotlights in a stack and alternate
mediaPosition; setheadingLevelto 3 under a section heading.
Public CSS variables
| Variable | Token |
|---|---|
--feature-spotlight-accent | accent |
--feature-spotlight-on-accent | onAccent |
--feature-spotlight-accent-hover-mix | accentHoverMix |
--feature-spotlight-ink | ink |
--feature-spotlight-muted | muted |
--feature-spotlight-hairline | hairline |
--feature-spotlight-surface | surface |
Accessibility#
On this page- The section is labelled by its heading, an h2 by default; set
headingLevelto 3 when the spotlight sits under another section's h2. - The copy comes before the media in the DOM at every width, so reading and tab order put the heading and actions first.
mediaPositionmoves the media with CSS only, from the lg breakpoint. - Image alt text comes from you. Say what the screenshot shows that the copy does not; pass
alt: ''only when the image is decoration. - A video passed through the media snippet is yours to make accessible: native controls, no autoplay with sound, a captions track when it has speech, and an accessible name.
- Points are a list (role="list" keeps list semantics where list-style is removed); the check marks are decorative and hidden from assistive technology.
- Actions are links with a two-pixel accent focus ring on :focus-visible, at least 44 px tall. The text link's arrow is decorative and flips under right-to-left text.
- White on-accent text measures 17.7:1 on the neutral accent (
#18181b) and 6.7:1 on blue (#1d4ed8); the hover fills keep it above 4.5: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 Japanese and Chinese break between phrases.
- Element IDs come from
$props.id(), so two spotlights 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 and check marks).
- The component cannot check that alt text is accurate or that a video has captions; 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.