Add Hover gallery component (#3246)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-10-02 13:07:05 +02:00
1 parent ace9a4f123
commit f7c848a938
21 files changed
+374

No files matched your search

+7
View File
@@ -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.
+1
View File
@@ -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';
+11
View File
@@ -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;
+118
View File
@@ -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;
}
}
}
@@ -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.
<Example centered>
<div class="hover-gallery rounded w-33">
<div class="hover-gallery-item"><img src="/static/products/backpack-1.jpg" alt="Black leather backpack, front view" /></div>
<div class="hover-gallery-item"><img src="/static/products/backpack-2.jpg" alt="Black leather backpack, view from the front left" /></div>
<div class="hover-gallery-item"><img src="/static/products/backpack-3.jpg" alt="Black leather backpack, side view" /></div>
<div class="hover-gallery-item"><img src="/static/products/backpack-4.jpg" alt="Black leather backpack, back view" /></div>
</div>
</Example>
## Indicators
Add `.hover-gallery-indicators` to show a bar for each image. The bar of the image on display is lit.
<Example centered>
<div class="hover-gallery hover-gallery-indicators rounded w-33">
<div class="hover-gallery-item"><img src="/static/products/backpack-1.jpg" alt="Black leather backpack, front view" /></div>
<div class="hover-gallery-item"><img src="/static/products/backpack-2.jpg" alt="Black leather backpack, view from the front left" /></div>
<div class="hover-gallery-item"><img src="/static/products/backpack-3.jpg" alt="Black leather backpack, side view" /></div>
<div class="hover-gallery-item"><img src="/static/products/backpack-4.jpg" alt="Black leather backpack, back view" /></div>
</div>
</Example>
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.
<Example centered>
<div class="hover-gallery hover-gallery-indicators rounded w-50" style="--tblr-hover-gallery-aspect-ratio: 16 / 9">
<div class="hover-gallery-item"><img src="/static/photos/blue-sofa-with-pillows-in-a-designer-living-room-interior.jpg" alt="Blue sofa with pillows in a living room" /></div>
<div class="hover-gallery-item"><img src="/static/photos/city-lights-reflected-in-the-water-at-night.jpg" alt="City lights reflected in the water at night" /></div>
<div class="hover-gallery-item"><img src="/static/photos/colorful-exotic-flowers-and-greenery.jpg" alt="Colorful exotic flowers and greenery" /></div>
</div>
</Example>
## In a card
Add `.card-img-top` to use the gallery in place of the card image.
<Example centered>
<div class="card w-33">
<div class="hover-gallery hover-gallery-indicators card-img-top">
<div class="hover-gallery-item"><img src="/static/products/watch-1.jpg" alt="Steel watch with a black dial, front view" /></div>
<div class="hover-gallery-item"><img src="/static/products/watch-2.jpg" alt="Steel watch, view from the front left" /></div>
<div class="hover-gallery-item"><img src="/static/products/watch-4.jpg" alt="Steel watch, back view" /></div>
</div>
<div class="card-body">
<h3 class="card-title">Steel watch</h3>
<div class="text-secondary">$249</div>
</div>
</div>
</Example>
## 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.
+72
View File
@@ -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 }))
---
<DefaultLayout title="Hover gallery" pageMenu="base.hover-gallery" description="Hover galleries show one image and switch to the next ones as the pointer moves across.">
<DocsLink slot="page-header-actions" path="/ui/components/hover-gallery" />
<div class="row row-cards">
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Basic</CardTitle>
<CardSubtitle>Wrap each image in <code>.hover-gallery-item</code>; every item is one hover column.</CardSubtitle>
<HoverGallery images={backpack} class="rounded" />
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>With indicators</CardTitle>
<CardSubtitle>Add <code>.hover-gallery-indicators</code> to show which image is on display.</CardSubtitle>
<HoverGallery images={watch} showIndicators class="rounded" />
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Aspect ratio</CardTitle>
<CardSubtitle>Set <code>--tblr-hover-gallery-aspect-ratio</code> to change the shape.</CardSubtitle>
<HoverGallery images={landscapes} showIndicators aspectRatio="16 / 9" class="rounded" />
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<HoverGallery images={backpack} showIndicators class="card-img-top" />
<CardBody>
<CardTitle>Product card</CardTitle>
<CardSubtitle>Use the gallery with <code>.card-img-top</code> in place of the card image.</CardSubtitle>
<div class="h3 m-0">$189</div>
</CardBody>
</Card>
</div>
</div>
</DefaultLayout>
+32
View File
@@ -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,
}))
---
<ScreenshotLayout title="Hover gallery" columns={1}>
<Card>
<HoverGallery images={watch} showIndicators class="card-img-top" />
<CardBody>
<CardTitle>Steel watch</CardTitle>
<CardSubtitle>Brushed steel case, black dial and a leather strap.</CardSubtitle>
<div class="d-flex align-items-center">
<div class="h2 m-0">$249</div>
<Button text="Add to cart" color="primary" icon="shopping-cart" class="ms-auto" />
</div>
</CardBody>
</Card>
</ScreenshotLayout>
+1
View File
@@ -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 }]
+4
View File
@@ -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"
+5
View File
@@ -168,6 +168,11 @@
}
}
},
"hover-gallery": {
"url": "hover-gallery.html",
"title": "Hover gallery",
"badge": "New"
},
"legend": {
"url": "legend.html",
"title": "Legend"
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+32
View File
@@ -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
---
<div class:list={['hover-gallery', showIndicators && 'hover-gallery-indicators', className]} style={aspectRatio ? `--tblr-hover-gallery-aspect-ratio: ${aspectRatio}` : undefined} {...rest}>
{
images.map((image) => (
<div class="hover-gallery-item">
<img src={image.src} alt={image.alt ?? ''} />
</div>
))
}
</div>