diff --git a/.changeset/hover-gallery.md b/.changeset/hover-gallery.md
new file mode 100644
index 000000000..778197265
--- /dev/null
+++ b/.changeset/hover-gallery.md
@@ -0,0 +1,7 @@
+---
+"@tabler/core": minor
+"@tabler/preview": minor
+"@tabler/docs": minor
+---
+
+Added the Hover gallery component with `.hover-gallery`, `.hover-gallery-item` and `.hover-gallery-indicators` classes.
diff --git a/core/scss/_core.scss b/core/scss/_core.scss
index ca30366c8..56df7ac88 100644
--- a/core/scss/_core.scss
+++ b/core/scss/_core.scss
@@ -58,6 +58,7 @@
@forward 'ui/datagrid';
@forward 'ui/empty';
@forward 'ui/grid';
+@forward 'ui/hover-gallery';
@forward 'ui/icons';
@forward 'ui/images';
@forward 'ui/forms';
diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss
index 13844c1f2..dd97a60f7 100644
--- a/core/scss/_variables.scss
+++ b/core/scss/_variables.scss
@@ -1717,6 +1717,17 @@ $status-indicator-size: 2.5rem !default;
$status-circle-size: 0.75rem !default;
// scss-docs-end status-variables
+// scss-docs-start hover-gallery-variables
+$hover-gallery-aspect-ratio: 1 !default;
+$hover-gallery-indicator-size: 2px !default;
+$hover-gallery-indicator-gap: 0.25rem !default;
+$hover-gallery-indicator-padding: 0.5rem !default;
+$hover-gallery-indicator-color: $white !default;
+$hover-gallery-indicator-scrim-height: 40% !default;
+$hover-gallery-indicator-scrim-bg: linear-gradient(to top, rgba($black, 0.32), rgba($black, 0.2) 20%, rgba($black, 0.1) 45%, rgba($black, 0.03) 75%, transparent) !default;
+$hover-gallery-indicator-inactive-opacity: 0.5 !default;
+// scss-docs-end hover-gallery-variables
+
// scss-docs-start signal-variables
$signal-size: map.get(map.get($avatar-sizes, 'xs'), size) !default;
$signal-gap-ratio: 0.1 !default;
diff --git a/core/scss/ui/_hover-gallery.scss b/core/scss/ui/_hover-gallery.scss
new file mode 100644
index 000000000..dabb6b3fa
--- /dev/null
+++ b/core/scss/ui/_hover-gallery.scss
@@ -0,0 +1,118 @@
+@use '../config' as *;
+
+// Every item is an equal, invisible column; hovering a column shows its image over the whole gallery
+.hover-gallery {
+ // scss-docs-start hover-gallery-css-vars
+ --hover-gallery-aspect-ratio: #{$hover-gallery-aspect-ratio};
+ --hover-gallery-indicator-size: #{$hover-gallery-indicator-size};
+ --hover-gallery-indicator-gap: #{$hover-gallery-indicator-gap};
+ --hover-gallery-indicator-padding: #{$hover-gallery-indicator-padding};
+ --hover-gallery-indicator-color: #{$hover-gallery-indicator-color};
+ --hover-gallery-indicator-scrim-height: #{$hover-gallery-indicator-scrim-height};
+ --hover-gallery-indicator-scrim-bg: #{$hover-gallery-indicator-scrim-bg};
+ --hover-gallery-indicator-inactive-opacity: #{$hover-gallery-indicator-inactive-opacity};
+ // scss-docs-end hover-gallery-css-vars
+ position: relative;
+ display: flex;
+ aspect-ratio: var(--hover-gallery-aspect-ratio);
+ overflow: hidden;
+}
+
+.hover-gallery-item {
+ flex: 1;
+
+ img {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ object-fit: cover;
+ opacity: 0;
+ }
+
+ &:first-child img,
+ &:hover img {
+ opacity: 1;
+ }
+
+ &:hover img {
+ z-index: 1;
+ }
+}
+
+// One bar per item at the bottom edge, lit for the image on display
+.hover-gallery-indicators {
+ gap: var(--hover-gallery-indicator-gap);
+ padding: var(--hover-gallery-indicator-padding);
+
+ // Darkens the bottom edge so the bars stay visible on any image
+ &::after {
+ position: absolute;
+ inset: auto 0 0;
+ z-index: 1;
+ height: var(--hover-gallery-indicator-scrim-height);
+ pointer-events: none;
+ content: '';
+ background: var(--hover-gallery-indicator-scrim-bg);
+ }
+
+ .hover-gallery-item {
+ display: flex;
+ align-items: flex-end;
+
+ // The gap between columns must not drop the hover, so the item reaches over it
+ padding-inline: calc(var(--hover-gallery-indicator-gap) * 0.5);
+ margin-inline: calc(var(--hover-gallery-indicator-gap) * -0.5);
+
+ &::after {
+ position: relative;
+ z-index: 2;
+ flex: 1;
+ height: var(--hover-gallery-indicator-size);
+ content: '';
+ background: var(--hover-gallery-indicator-color);
+ opacity: var(--hover-gallery-indicator-inactive-opacity);
+ @include border-radius(var(--hover-gallery-indicator-size));
+ }
+
+ &:hover::after {
+ opacity: 1;
+ }
+ }
+
+ &:not(:hover) .hover-gallery-item:first-child::after {
+ opacity: 1;
+ }
+}
+
+// Without hover the same markup becomes a swipeable strip
+@media (hover: none) {
+ .hover-gallery {
+ gap: 0;
+ padding: 0;
+ overflow-x: auto;
+ scroll-snap-type: x mandatory;
+ scrollbar-width: none;
+ }
+
+ .hover-gallery::after {
+ display: none;
+ }
+
+ .hover-gallery .hover-gallery-item {
+ flex: 0 0 100%;
+ padding: 0;
+ margin: 0;
+ scroll-snap-align: start;
+
+ img {
+ position: static;
+ opacity: 1;
+ }
+
+ &::after {
+ display: none;
+ }
+ }
+}
diff --git a/docs/content/ui/components/hover-gallery.mdx b/docs/content/ui/components/hover-gallery.mdx
new file mode 100644
index 000000000..59df048d9
--- /dev/null
+++ b/docs/content/ui/components/hover-gallery.mdx
@@ -0,0 +1,91 @@
+---
+title: Hover gallery
+summary: A set of images that shows one at a time and switches as the pointer moves across it.
+description: Show several photos in the space of one with the hover gallery component - a good fit for product cards, portfolios and listings.
+related: [/ui/components/carousel, /ui/components/card]
+source: core/scss/ui/_hover-gallery.scss
+css-vars: hover-gallery
+classnames:
+ component:
+ - class: hover-gallery
+ desc: Container element with a fixed aspect ratio
+ part:
+ - class: hover-gallery-item
+ desc: Wraps one image and makes one hover column
+ modifier:
+ - class: hover-gallery-indicators
+ desc: Adds a bar for each item at the bottom edge and lights the one on display
+---
+import Example from '@components/Example.astro';
+
+## Overview
+
+A hover gallery holds several images in one box. The first image shows by default. Each item is an invisible column, and the image of the column under the pointer covers the whole gallery. It works without JavaScript.
+
+## Basic example
+
+Wrap each image in a `.hover-gallery-item` and put the items in a `.hover-gallery`. Use as many items as you need.
+
+
+
+
+
+## Indicators
+
+Add `.hover-gallery-indicators` to show a bar for each image. The bar of the image on display is lit.
+
+
+
+
+
+The bars are white and sit on a soft dark gradient, so they stay visible on light and dark photos. Set `--tblr-hover-gallery-indicator-scrim-bg` to `none` to remove the gradient, and `--tblr-hover-gallery-indicator-color` to change the bars.
+
+## Aspect ratio
+
+The gallery is a square by default and the images are cropped to fill it. Set `--tblr-hover-gallery-aspect-ratio` to change the shape.
+
+
+
+
+
+## In a card
+
+Add `.card-img-top` to use the gallery in place of the card image.
+
+
+
+
+
+## Touch devices
+
+On devices without hover, the gallery becomes a horizontal strip. Users swipe to see the next image, and each image snaps into place. The indicators are hidden there.
+
+## Accessibility
+
+- Give every image an `alt` text that says what this photo shows, not only the product name.
+- Keyboard users see only the first image. Do not put information in the other images that is missing elsewhere on the page.
+- When the gallery sits inside a link, the whole gallery leads to one place. Do not link single images to different pages.
diff --git a/preview/pages/hover-gallery.astro b/preview/pages/hover-gallery.astro
new file mode 100644
index 000000000..2ad9229d2
--- /dev/null
+++ b/preview/pages/hover-gallery.astro
@@ -0,0 +1,72 @@
+---
+import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
+import Card from '@ui/Card.astro'
+import CardBody from '@ui/CardBody.astro'
+import CardTitle from '@ui/CardTitle.astro'
+import CardSubtitle from '@ui/CardSubtitle.astro'
+import DocsLink from '@ui/DocsLink.astro'
+import HoverGallery from '@ui/HoverGallery.astro'
+import photos from '@data/photos.json'
+import { staticPath } from '@shared/lib/assets'
+
+const backpack = ['Black leather backpack, front view', 'Black leather backpack, view from the front left', 'Black leather backpack, side view', 'Black leather backpack, back view with the shoulder straps', 'Brass buckle and leather grain of the backpack'].map((alt, index) => ({
+ src: staticPath(`products/backpack-${index + 1}.jpg`),
+ alt,
+}))
+
+const watch = ['Steel watch with a black dial, front view', 'Steel watch with a black dial, view from the front left', 'Steel watch, side view with the crown', 'Steel watch, back view with the case back and buckle', 'Close-up of the watch dial, bezel and crown'].map((alt, index) => ({
+ src: staticPath(`products/watch-${index + 1}.jpg`),
+ alt,
+}))
+
+const landscapeFiles = ['blue-sofa-with-pillows-in-a-designer-living-room-interior.jpg', 'city-lights-reflected-in-the-water-at-night.jpg', 'colorful-exotic-flowers-and-greenery.jpg']
+
+const landscapes = photos.filter((photo) => landscapeFiles.includes(photo.file)).map((photo) => ({ src: staticPath(`photos/${photo.file}`), alt: photo.title }))
+---
+
+
+
+
+
+
+
+
+ Basic
+ Wrap each image in .hover-gallery-item; every item is one hover column.
+
+
+
+
+
+
+
+
+ With indicators
+ Add .hover-gallery-indicators to show which image is on display.
+
+
+
+
+
+
+
+
+ Aspect ratio
+ Set --tblr-hover-gallery-aspect-ratio to change the shape.
+
+
+
+
+
+
+
+
+
+ Product card
+ Use the gallery with .card-img-top in place of the card image.
+ $189
+
+
+
+
+
diff --git a/screenshots/pages/hover-gallery.astro b/screenshots/pages/hover-gallery.astro
new file mode 100644
index 000000000..b4c0bd665
--- /dev/null
+++ b/screenshots/pages/hover-gallery.astro
@@ -0,0 +1,32 @@
+---
+// Hover gallery for the stills and the video recorder (`pnpm run record hover-gallery`):
+// a product card whose photo changes as the cursor crosses it, driven by
+// steps/hover-gallery.json. As a still it shows the first photo with its indicator lit.
+import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
+import Card from '@ui/Card.astro'
+import CardBody from '@ui/CardBody.astro'
+import CardTitle from '@ui/CardTitle.astro'
+import CardSubtitle from '@ui/CardSubtitle.astro'
+import Button from '@ui/Button.astro'
+import HoverGallery from '@ui/HoverGallery.astro'
+import { staticPath } from '@shared/lib/assets'
+
+const watch = ['Steel watch with a black dial, front view', 'Steel watch with a black dial, view from the front left', 'Steel watch, side view with the crown', 'Steel watch, back view with the case back and buckle', 'Close-up of the watch dial, bezel and crown'].map((alt, index) => ({
+ src: staticPath(`products/watch-${index + 1}.jpg`),
+ alt,
+}))
+---
+
+
+
+
+
+ Steel watch
+ Brushed steel case, black dial and a leather strap.
+
+
+
+
diff --git a/screenshots/steps/hover-gallery.json b/screenshots/steps/hover-gallery.json
new file mode 100644
index 000000000..af4583949
--- /dev/null
+++ b/screenshots/steps/hover-gallery.json
@@ -0,0 +1 @@
+[{ "wait": 600 }, { "move": ".hover-gallery-item:nth-child(1)" }, { "wait": 500 }, { "move": ".hover-gallery-item:nth-child(2)", "duration": 500 }, { "wait": 700 }, { "move": ".hover-gallery-item:nth-child(3)", "duration": 500 }, { "wait": 700 }, { "move": ".hover-gallery-item:nth-child(4)", "duration": 500 }, { "wait": 700 }, { "move": ".hover-gallery-item:nth-child(5)", "duration": 500 }, { "wait": 900 }, { "move": ".hover-gallery-item:nth-child(2)", "duration": 900 }, { "wait": 700 }, { "move": ".btn-primary" }, { "wait": 900 }]
diff --git a/shared/data/docs.json b/shared/data/docs.json
index ce3d5020f..352b3ad3a 100644
--- a/shared/data/docs.json
+++ b/shared/data/docs.json
@@ -222,6 +222,10 @@
"title": "Empty state",
"url": "/ui/components/empty"
},
+ {
+ "title": "Hover gallery",
+ "url": "/ui/components/hover-gallery"
+ },
{
"title": "Icon",
"url": "/ui/components/icon"
diff --git a/shared/data/menu.json b/shared/data/menu.json
index f0df71c6d..42ea8719a 100644
--- a/shared/data/menu.json
+++ b/shared/data/menu.json
@@ -168,6 +168,11 @@
}
}
},
+ "hover-gallery": {
+ "url": "hover-gallery.html",
+ "title": "Hover gallery",
+ "badge": "New"
+ },
"legend": {
"url": "legend.html",
"title": "Legend"
diff --git a/shared/static/products/backpack-1.jpg b/shared/static/products/backpack-1.jpg
new file mode 100644
index 000000000..1ec56569a
Binary files /dev/null and b/shared/static/products/backpack-1.jpg differ
diff --git a/shared/static/products/backpack-2.jpg b/shared/static/products/backpack-2.jpg
new file mode 100644
index 000000000..0349f6fde
Binary files /dev/null and b/shared/static/products/backpack-2.jpg differ
diff --git a/shared/static/products/backpack-3.jpg b/shared/static/products/backpack-3.jpg
new file mode 100644
index 000000000..7db1a7c77
Binary files /dev/null and b/shared/static/products/backpack-3.jpg differ
diff --git a/shared/static/products/backpack-4.jpg b/shared/static/products/backpack-4.jpg
new file mode 100644
index 000000000..ee47ff58a
Binary files /dev/null and b/shared/static/products/backpack-4.jpg differ
diff --git a/shared/static/products/backpack-5.jpg b/shared/static/products/backpack-5.jpg
new file mode 100644
index 000000000..5ba9c7134
Binary files /dev/null and b/shared/static/products/backpack-5.jpg differ
diff --git a/shared/static/products/watch-1.jpg b/shared/static/products/watch-1.jpg
new file mode 100644
index 000000000..029c34796
Binary files /dev/null and b/shared/static/products/watch-1.jpg differ
diff --git a/shared/static/products/watch-2.jpg b/shared/static/products/watch-2.jpg
new file mode 100644
index 000000000..aac6cb7b8
Binary files /dev/null and b/shared/static/products/watch-2.jpg differ
diff --git a/shared/static/products/watch-3.jpg b/shared/static/products/watch-3.jpg
new file mode 100644
index 000000000..503bf996e
Binary files /dev/null and b/shared/static/products/watch-3.jpg differ
diff --git a/shared/static/products/watch-4.jpg b/shared/static/products/watch-4.jpg
new file mode 100644
index 000000000..f1836f448
Binary files /dev/null and b/shared/static/products/watch-4.jpg differ
diff --git a/shared/static/products/watch-5.jpg b/shared/static/products/watch-5.jpg
new file mode 100644
index 000000000..ead261cf9
Binary files /dev/null and b/shared/static/products/watch-5.jpg differ
diff --git a/shared/ui/HoverGallery.astro b/shared/ui/HoverGallery.astro
new file mode 100644
index 000000000..5996b413a
--- /dev/null
+++ b/shared/ui/HoverGallery.astro
@@ -0,0 +1,32 @@
+---
+// Hover gallery (.hover-gallery): one image on display, the others show as the pointer moves across.
+
+export interface HoverGalleryImage {
+ src: string
+ alt?: string
+}
+
+interface Props {
+ /** one `.hover-gallery-item` per image; the first one shows by default */
+ images: HoverGalleryImage[]
+ /** core/scss/ui/_hover-gallery.scss `.hover-gallery-indicators` */
+ showIndicators?: boolean
+ /** sets `--tblr-hover-gallery-aspect-ratio`, e.g. "16 / 9" */
+ aspectRatio?: string | undefined
+ class?: string
+ /** remaining attributes (id, data-*, aria-*, …) are forwarded to the root element */
+ [key: string]: unknown
+}
+
+const { images, showIndicators = false, aspectRatio, class: className, ...rest } = Astro.props
+---
+
+
+ {
+ images.map((image) => (
+
+

+
+ ))
+ }
+