Image
A responsive image in an aspect-ratio frame, with a loading shimmer and an opt-in lightbox.
Live demo
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.
Props
12 props, straight from the manifest.
| Prop | Type | Default | Bindings | Description |
|---|---|---|---|---|
Appearance
States
loading
While the image loads: the shimmer placeholder shows and the image is held at zero opacity.
error
When the image fails to load: a muted frame with a warning glyph replaces the picture.
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
The aspect-ratio box that clips the image and holds its shape while loading.
placeholder
The shimmer shown behind the image while it loads.
caption
The optional caption rendered as a figcaption below the frame.
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.
zoom
The hover- and focus-revealed zoom button that opens the lightbox when trigger="button".
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.
audio
The mute/unmute toggle shown on a hover video when hover-audio allows sound.
Tokens & coverage
What the component consumes, checked live against what the algorithm produces.
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
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
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>
