Skip to main content

Image

Info:Media
22

A responsive image in an aspect-ratio frame, with a loading shimmer and an opt-in lightbox.

picture photo img figure lightbox aspect ratio

Live demo

live · @xtyle/astro

Image

Framed with a caption

An abstract gradient landscape
A 16/9 frame that holds its space while the image loads

Hover to preview

Hover or focus a cover and the still comes alive and plays a preview clip; leaving resets it. The preview can be a moving image, a nested carousel, or a video via hover-src (with an optional unmute toggle).

xtyle

a nested carousel

An abstract gradient landscape

a moving preview

Nebula Drifter, a landscape that comes alive

hover-src video

Lightbox

Click or focus and press Enter to open the full image over a scrim; Escape or the backdrop closes it.

An abstract gradient landscape
A framed emblem
An abstract gradient landscape

Zoom button

With trigger="button" the frame stops being the click target; a zoom button reveals on hover and focus, so click-to-zoom sits beside selectable text without fighting it.

An abstract gradient landscape
A framed emblem

Standalone lightbox for raw images

One <xtyle-lightbox> mounted in the card opens any [data-xtyle-lightbox] in its scope, including plain <img> markup no component rendered (a markdown or CMS body). Click a thumbnail, or focus it and press Enter, to open the shared dialog.

Lightbox in a frosted panel

The lightbox opens against the viewport, so it stays centered even inside a panel whose backdrop-filter or transform would otherwise pin a modal to its own box.

An abstract gradient landscape

Fit and radius

Emblem, cover

cover

Emblem, contain

contain

Scene, no radius

radius none

Scene, large radius

radius lg

Load failure

When the image fails to load, a muted frame with a warning glyph replaces it.

A missing image

Image wraps a picture in a frame that holds its shape while it loads: give it a ratio and the box reserves the space so the page never reflows when the pixels arrive, and a shimmer placeholder fills the frame until they do, fading the image in on load. It stays honest with JavaScript off; the image renders at full opacity with no shimmer to hide it, and the blur-up is a progressive enhancement layered on top, never a curtain that traps the image behind a script that failed to run.

fit chooses cover or contain, radius rounds the frame off the scale, an optional caption renders a figcaption, and native loading="lazy" defers off-screen images for free. Set lightbox and the frame becomes a control that opens the full image in a top-layer dialog: a scrim, a close button, backdrop and Escape to dismiss, and focus handled by the platform, all wired only when the runtime is present so the static markup stays inert and safe. By default the whole frame is the zoom target; where that sits next to selectable prose, trigger="button" moves the affordance onto a dedicated zoom button that reveals on hover and focus, so a click meant for the surrounding text never trips the modal. The per-<Image> lightbox is the common case; for images the component never rendered (a marked/CMS body injected as raw {@html}, a gallery of mixed sources), the same lightbox is available standalone: mount one <xtyle-lightbox> and it opens any [data-xtyle-lightbox] element in its scope (promoting non-interactive ones to keyboard-operable), or call the imperative openLightbox(src, { alt, caption }) from @xtyle/core/elements from any click handler. One controller, one dialog, every image source.

When to use

How this component composes with the rest of the set.

Give every content image a ratio so the frame reserves its space and the page doesn't reflow as images load.
Drop an Image into a Card media slot for a thumbnail with a caption, or into a Grid for a gallery.
Set lightbox on gallery thumbnails so a click opens the full image over a scrim; the close control uses the built-in close icon.
For images the component never rendered (a markdown/CMS body, a {@html} block, mixed sources), mount one <xtyle-lightbox> and tag images with data-xtyle-lightbox, or call openLightbox(src, { alt, caption }); one shared dialog, not a second bespoke lightbox.
Pair fit="contain" with a framed ratio for logos or art that must not be cropped.
Add hover-src (a video/gif) or a hover slot for a play-on-hover preview: the still animates on hover, then resets on leave. Inside a Carousel, each Image slide can carry its own preview and the carousel's hover-pause fires in the same gesture; drop a nested <xtyle-carousel slot="hover"> in for a mini gallery-on-hover.

Props

12 props, straight from the manifest.

PropTypeDefaultBindingsDescription
src string
html svelte astro
The image URL.
alt string
html svelte astro
The alternative text. Leave empty only for a decorative image; it carries the accessible name and the lightbox trigger's label.
ratio string
html svelte astro
An aspect ratio for the frame (a CSS `aspect-ratio` value like `16/9` or `1/1`). Omit to size to the image's natural ratio.
fit ImageFit
cover contain
cover
html svelte astro
How the image fills the frame: `cover` crops to fill, `contain` fits the whole image.
radius ImageRadius
none sm md lg
md
html svelte astro
The frame's corner rounding off the radius scale: `none`, `sm`, `md`, `lg`.
loading ImageLoading
lazy eager
lazy
html svelte astro
The native browser loading strategy: `lazy` defers off-screen images, `eager` loads immediately.
lightbox boolean false
html svelte astro
Makes the frame a control that opens the full image in a top-layer dialog. Wired only when the runtime is present.
trigger ImageTrigger
frame button
frame
html svelte astro
What opens the lightbox: `frame` makes the whole image the zoom target, `button` moves it onto a dedicated zoom button that reveals on hover and focus, so click-to-zoom doesn't fight a text selection nearby. Only relevant with `lightbox`.
caption string
html svelte astro
An optional caption, rendered as a `figcaption` below the frame.
hover-src string
html svelte astro
A video (`.mp4`/`.webm`) or gif revealed on hover and focus: the still shows at rest, the media plays on hover (muted, looping), and it resets to the still on leave. The video is auto-detected by extension; the still `src` is its poster. A convenience over the `hover` slot for the single-media case. Wired only when the runtime is present.
hover-poster string
html svelte astro
An explicit poster still for the `hover-src` video, shown before it plays. Defaults to the image's own `src`.
hover-audio ImageHoverAudio
off on
html svelte astro
Allows sound on the hover video and shows a mute/unmute toggle: `off` starts muted (click to unmute), `on` starts with sound where the browser permits. Omit for a silent preview with no toggle (the default).

Appearance

States

loading

.xtyle-image__frame[data-loading]

While the image loads: the shimmer placeholder shows and the image is held at zero opacity.

error

.xtyle-image__frame[data-error]

When the image fails to load: a muted frame with a warning glyph replaces the picture.

hover-active

.xtyle-image__frame[data-hover-active]

While the frame is hovered or focused with hover-preview content: the overlay is faded in and any hover video is playing.

Anatomy

The named parts that make up the component, with their selectors.

frame

.xtyle-image__frame

The aspect-ratio box that clips the image and holds its shape while loading.

--bg-2 --radius-md

placeholder

.xtyle-image__placeholder

The shimmer shown behind the image while it loads.

--bg-2 --bg-3 --duration-slow

caption

.xtyle-image__caption

The optional caption rendered as a figcaption below the frame.

--fg-2 --text-sm

lightbox

.xtyle-lightbox

The full-image viewer: an <xtyle-dialog> restyled by the .xtyle-lightbox host class, so it inherits the dialog's scrim, close button, focus trap, Escape, and body-portal, and stays app-overridable.

--bg-1

zoom

.xtyle-image__zoom

The hover- and focus-revealed zoom button that opens the lightbox when trigger="button".

--bg-1 --fg-1 --radius-full --surface-overlay-border --elevation-3

hover

.xtyle-image__hover

The hover-preview overlay: hidden until the frame is hovered or focused, then it cross-fades in over the still. Holds the hover slot content (a <video>, images, or a nested <xtyle-carousel>) or the media generated from hover-src.

--duration-slow --ease-standard

audio

.xtyle-image__audio

The mute/unmute toggle shown on a hover video when hover-audio allows sound.

--bg-1 --fg-1 --radius-full --surface-overlay-border --elevation-3

Tokens & coverage

What the component consumes, checked live against what the algorithm produces.

Success:fully covered 22/22 consumed tokens produced default register: 299 tokens

Live coverage check against the xtyle-default register (derive(xtyleDefault, { anchors })coverComponent(manifest, register)). Every token this component consumes must be a key the algorithm produces.

--bg-1 --bg-2 --bg-3 --border-thick --border-thin --duration-fast --duration-slow --ease-standard --elevation-3 --fg-1 --fg-2 --leading-normal --radius-full --radius-lg --radius-md --radius-sm --ring --space-2 --space-3 --space-7 --surface-overlay-border --text-sm

Slots

hover
html svelte astro

Custom hover-preview content, revealed on hover and focus. Put a <video slot="hover"> (played and reset by the component), a gif <img>, several images, or a whole nested <xtyle-carousel slot="hover" autoplay> here. Takes precedence over hover-src.

Accessibility

Always set alt: it names the image for assistive tech and labels the lightbox trigger. Use an empty alt only for a purely decorative image.
The lightbox opens a native modal dialog, so focus is trapped inside it, Escape closes it, and focus returns to the trigger on close, all handled by the platform.
The standalone <xtyle-lightbox> promotes any non-interactive [data-xtyle-lightbox] trigger to keyboard-operable (role="button", a tab stop, an aria-label from the image alt) and opens on Enter or Space, so the delegated path is never mouse-only.
trigger="button" renders a real <button> for the zoom control, so it is keyboard-focusable and activates with Enter or Space; it reveals on hover and on focus so a keyboard user always sees it, and stays visible on touch, where there is no hover. Both trigger modes carry the image's alt as their accessible name.
The blur-up and lightbox are progressive enhancements: with no JavaScript the image renders normally and is never hidden behind a script.
The loading shimmer and the fade-in honor prefers-reduced-motion.
The hover preview is a visual enhancement over the still: the overlay is aria-hidden, so the image's alt remains the single accessible name and a screen reader is not told the picture changed.
The preview reveals on focus as well as hover, so a keyboard user reaches it too: a hover-only frame becomes a focus stop that shows the preview and restores the still on blur. It is never mouse-only.
A hover video plays muted so it can autoplay; with hover-audio an unmute toggle appears, and unmuting is a real click (a user gesture), so sound is never forced on. Under prefers-reduced-motion the preview does not reveal or play, leaving the still in place.

Code

Framed with a caption

A responsive image in a 16/9 frame with a caption; the frame reserves its space while the image loads.

<xtyle-image
	src="/photo.jpg"
	alt="A city skyline at dusk"
	ratio="16/9"
	caption="Downtown, golden hour"
></xtyle-image>
<script lang="ts">
	import { Image } from "@xtyle/svelte";
</script>

<Image src="/photo.jpg" alt="A city skyline at dusk" ratio="16/9" />
---
import Image from "@xtyle/astro/Image.astro";
---

<Image src="/photo.jpg" alt="A city skyline at dusk" ratio="16/9" caption="Downtown, golden hour" />

Hover preview

A play-on-hover preview: hover-src plays a muted, looping video on hover and focus (resetting to the still on leave), hover-audio adds an unmute toggle, and the hover slot takes any content, including a whole nested Carousel.

<!-- A video preview on hover: the still is the poster,
     the clip plays muted on hover, and resets to the still on leave. -->
<xtyle-image src="/cover.jpg" alt="Nebula Drifter" ratio="16/9" hover-src="/preview.mp4"></xtyle-image>

<!-- Allow sound: a mute/unmute toggle appears, starting muted. -->
<xtyle-image src="/cover.jpg" alt="Nebula Drifter" ratio="16/9"
	hover-src="/preview.mp4" hover-audio="off"></xtyle-image>

<!-- Or reveal a whole nested carousel of screenshots on hover. -->
<xtyle-image src="/cover.jpg" alt="Nebula Drifter" ratio="16/9">
	<xtyle-carousel slot="hover" autoplay loop transition="fade" pause-on-hover="false" interval="1200" dots="false" controls="false" label="Screenshots">
		<xtyle-image src="/shot-1.jpg" alt="Screenshot 1" ratio="16/9"></xtyle-image>
		<xtyle-image src="/shot-2.jpg" alt="Screenshot 2" ratio="16/9"></xtyle-image>
		<xtyle-image src="/shot-3.jpg" alt="Screenshot 3" ratio="16/9"></xtyle-image>
	</xtyle-carousel>
</xtyle-image>
<script lang="ts">
	import { Image, Carousel } from "@xtyle/svelte";
</script>

<!-- A video preview on hover -->
<Image src="/cover.jpg" alt="Nebula Drifter" ratio="16/9" hoverSrc="/preview.mp4" />

<!-- A nested carousel of screenshots on hover -->
<Image src="/cover.jpg" alt="Nebula Drifter" ratio="16/9">
	<Carousel slot="hover" autoplay loop transition="fade" pauseOnHover={false} interval={1200} dots={false} controls={false} label="Screenshots">
		<Image src="/shot-1.jpg" alt="Screenshot 1" ratio="16/9" />
		<Image src="/shot-2.jpg" alt="Screenshot 2" ratio="16/9" />
		<Image src="/shot-3.jpg" alt="Screenshot 3" ratio="16/9" />
	</Carousel>
</Image>
---
import Image from "@xtyle/astro/Image.astro";
import Carousel from "@xtyle/astro/Carousel.astro";
---

<!-- A video preview on hover -->
<Image src="/cover.jpg" alt="Nebula Drifter" ratio="16/9" hoverSrc="/preview.mp4" />

<!-- A nested carousel of screenshots on hover -->
<Image src="/cover.jpg" alt="Nebula Drifter" ratio="16/9">
	<Carousel slot="hover" autoplay loop transition="fade" pauseOnHover={false} interval={1200} dots={false} controls={false} label="Screenshots">
		<Image src="/shot-1.jpg" alt="Screenshot 1" ratio="16/9" />
		<Image src="/shot-2.jpg" alt="Screenshot 2" ratio="16/9" />
		<Image src="/shot-3.jpg" alt="Screenshot 3" ratio="16/9" />
	</Carousel>
</Image>

Lightbox

Set lightbox and the frame opens the full image in a top-layer dialog on click.

<xtyle-image
	src="/photo.jpg"
	alt="A city skyline at dusk"
	ratio="4/3"
	lightbox
></xtyle-image>
<script lang="ts">
	import { Image } from "@xtyle/svelte";
</script>

<Image src="/photo.jpg" alt="A city skyline at dusk" ratio="4/3" lightbox />
---
import Image from "@xtyle/astro/Image.astro";
---

<Image src="/photo.jpg" alt="A city skyline at dusk" ratio="4/3" lightbox />

Zoom button

Add trigger="button" to move the zoom control onto a dedicated hover- and focus-revealed button, so click-to-zoom doesn't fight a selection in the text beside it.

<xtyle-image
	src="/photo.jpg"
	alt="A city skyline at dusk"
	ratio="4/3"
	lightbox
	trigger="button"
></xtyle-image>
<script lang="ts">
	import { Image } from "@xtyle/svelte";
</script>

<Image src="/photo.jpg" alt="A city skyline at dusk" ratio="4/3" lightbox trigger="button" />
---
import Image from "@xtyle/astro/Image.astro";
---

<Image src="/photo.jpg" alt="A city skyline at dusk" ratio="4/3" lightbox trigger="button" />

Standalone lightbox for any image source

Mount one <xtyle-lightbox> and it opens any [data-xtyle-lightbox] in its scope (including raw {@html}/markdown images a component can't wrap), or call openLightbox(src, { alt, caption }) imperatively. One controller and one dialog serve every image on the page.

<!-- Mount the controller once, anywhere in the page -->
<xtyle-lightbox scope="#article"></xtyle-lightbox>

<!-- Any [data-xtyle-lightbox] in scope opens the shared lightbox, including
     raw markup a component never rendered (a marked / CMS body). -->
<article id="article">
	<img src="/thumb.jpg" alt="A city skyline at dusk" data-xtyle-lightbox
		data-lightbox-src="/full.jpg" data-lightbox-caption="Downtown at dusk" />
</article>
<script lang="ts">
	import { openLightbox } from "@xtyle/core/elements";

	// Drive the same lightbox from any handler (a thumbnail grid, a keyboard
	// shortcut, a router event) with no component wrapping the image.
	function view(src: string, alt: string) {
		openLightbox(src, { alt, caption: alt });
	}
</script>

<button onclick={() => view("/full.jpg", "A city skyline at dusk")}>
	<img src="/thumb.jpg" alt="A city skyline at dusk" />
</button>