Radial Burst Gallery
Images burst out from the centre into a ring that slowly orbits; selecting one flies it to the centre while the rest shrink back onto the ring, with keyboard navigation.
Arrange a set of images into a ring that breathes. When it scrolls into view, each card bursts out from the centre onto the ring with a staggered spring, then the whole ring slowly orbits, easing smoothly to a stop when the pointer rests on a card or keyboard focus is inside. Select any card and it flies to the centre with a shared layout animation, growing larger as its caption fades in below, while the rest shrink and dim on the ring; click the centre card again, press Esc or click empty space to send it back. Each card can be an image URL or any React node (for example an image drawn with CSS gradients). The selection supports both controlled and uncontrolled modes.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add radial-burst-gallery.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<RadialBurstGallery/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
Installation
npx webberui@latest add https://webberui.com/r/radial-burst-gallery.en.jsonOr, once registries are configured in components.json, install it as @webberui/radial-burst-gallery.
Usage
import { RadialBurstGallery } from "@/components/ui/radial-burst-gallery";
<RadialBurstGallery
images={[
{ src: "/photos/ridge.jpg", alt: "Ridge in the Mist", caption: "Ridge in the Mist · Hehuan Mountain" },
{ src: "/photos/tide.jpg", alt: "After the Tide", caption: "After the Tide · Gaomei Wetland" },
{ src: "/photos/blue.jpg", alt: "Blue Hour", caption: "Blue Hour · North Coast" },
]}
radius={180}
period={40}
onSelectedChange={(index) => console.log("selected", index)}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
images | RadialBurstGalleryImage[] | — | The images, laid out clockwise from the top in array order |
radius | number | 180 | Ring radius (px); in narrow containers it shrinks to min(radius, container width/2 − card width) |
period | number | 40 | Seconds per full orbit; when omitted, the --wb-rbg-period CSS variable is read, falling back to 40 seconds |
stiffness | number | 180 | Spring stiffness for the burst and the flight; higher is snappier |
cardWidth | number | 96 | Width of the cards on the ring (px) |
aspect | string | "3/4" | Card aspect ratio as "width/height" |
centerSize | number | 260 | Width of the centre card once selected (px); shrinks automatically in narrow containers |
tiltToCenter | boolean | false | Rotate ring cards so their tops point outward; by default they stay upright |
selectedIndex | number | null | — | Controlled selected index (null means none); when omitted the component manages it internally |
defaultSelectedIndex | number | null | null | Initial selected index in uncontrolled mode |
onSelectedChange | (index: number | null) => void | — | Called when the selection changes; the argument is null when deselected |
pauseOnHover | boolean | true | Pause the orbit while the pointer is over a card |
className | string | — | Forwarded to the outermost container |
RadialBurstGalleryImage
| Field | Type | Default | Description |
|---|---|---|---|
src | string | — | Image URL; use either this or node — node wins when both are given |
alt | string | — | Alternative text, also used as the card button's aria-label (required) |
node | ReactNode | — | Custom content, for example an image drawn with CSS gradients |
caption | ReactNode | — | Caption shown below the centre card once selected |
How it works
- Geometry: n cards are spread evenly around a circle; card i sits at i/n × 360° plus the ring's rotation, clockwise from the top. Cards only translate, never rotate, unless
tiltToCenteris on. - Burst: the first time the gallery enters the viewport, each card starts at the centre at scale 0.3 and opacity 0 and springs out to the ring (
stiffness180 by default, damping 18), staggered by 50ms; the spring slightly overshoots the ring before settling. Scrolling away and back does not replay it. - Orbit: requestAnimationFrame advances the rotation according to
period; while the pointer is over a card, focus is inside the gallery or a card is selected, the speed eases to 0 over 150ms instead of stopping dead. The animation loop stops while the gallery is off screen. - Timing variable: when
periodis not passed, the component reads--wb-rbg-periodon its root element (40sor40000msboth work), so the rhythm can be tuned globally; an explicitperiodprop takes precedence. - Selection: the chosen card flies to the centre and grows to
centerSizevia Motion'slayoutId(prefixed withuseId, so several galleries on one page never interfere); the others shrink to 0.8 at 0.6 opacity. Ring cards are positioned with left/top rather than transforms so the shared layout animation measures the correct starting point. - Narrow containers: a ResizeObserver measures the container width, shrinking the radius to min(radius, container width/2 − card width) and keeping the centre card no wider than the container minus 32px.
Accessibility
- The root is
role="group"witharia-roledescription="gallery"; each card is atype="button"whosearia-labelcomes fromalt - Roving tabindex: Tab stops on a single card, ← / → (and ↑ / ↓) move focus in ring order, Home / End jump to the first and last cards; the ring stops orbiting while focus is inside
- Enter / Space selects and moves focus to the centre card; Esc, activating the centre card again or clicking empty space deselects and returns focus to the original card
- On selection an
aria-live="polite"region announces the position ("n of total") and the caption (or the alt text when there is no caption) - When the user has reduce motion enabled at the system level, there is no burst and no orbit — the cards sit statically on the ring — and selection becomes a cross-fade in the centre with no flight