diff --git a/flying-gallery/index.html b/flying-gallery/index.html
new file mode 100644
index 0000000..6851f82
--- /dev/null
+++ b/flying-gallery/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+ Flying Gallery — @wethegit/react-gallery
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 332b678..401a7dc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@wethegit/react-gallery",
- "version": "4.0.1",
+ "version": "4.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@wethegit/react-gallery",
- "version": "4.0.1",
+ "version": "4.0.2",
"license": "MIT",
"devDependencies": {
"@types/react": "~19.0.12",
diff --git a/src/flying-gallery/FlyingGallery.jsx b/src/flying-gallery/FlyingGallery.jsx
new file mode 100644
index 0000000..a573232
--- /dev/null
+++ b/src/flying-gallery/FlyingGallery.jsx
@@ -0,0 +1,146 @@
+import {
+ Gallery,
+ GalleryMain,
+ GalleryItem,
+ GalleryNav,
+ GalleryPagination,
+ GalleryPaginationItem,
+} from "../lib"
+import styles from "./flying-gallery.module.css"
+
+const ITEMS = [
+ {
+ id: 1,
+ image:
+ "https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?auto=format&fit=crop&w=1200&h=700&q=80",
+ thumb:
+ "https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?auto=format&fit=crop&w=200&h=120&q=60",
+ alt: "Sun rays through a forest",
+ },
+ {
+ id: 2,
+ image:
+ "https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&fit=crop&w=1200&h=700&q=80",
+ thumb:
+ "https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&fit=crop&w=200&h=120&q=60",
+ alt: "Snow-capped mountain peak at night",
+ },
+ {
+ id: 3,
+ image:
+ "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=1200&h=700&q=80",
+ thumb:
+ "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=200&h=120&q=60",
+ alt: "Mountain reflected in a still lake",
+ },
+ {
+ id: 4,
+ image:
+ "https://images.unsplash.com/photo-1501854140801-50d01698950b?auto=format&fit=crop&w=1200&h=700&q=80",
+ thumb:
+ "https://images.unsplash.com/photo-1501854140801-50d01698950b?auto=format&fit=crop&w=200&h=120&q=60",
+ alt: "Aerial view of green hills",
+ },
+ {
+ id: 5,
+ image:
+ "https://images.unsplash.com/photo-1465146344425-f00d5f5c8f07?auto=format&fit=crop&w=1200&h=700&q=80",
+ thumb:
+ "https://images.unsplash.com/photo-1465146344425-f00d5f5c8f07?auto=format&fit=crop&w=200&h=120&q=60",
+ alt: "Orange wildflower field",
+ },
+ {
+ id: 6,
+ image:
+ "https://images.unsplash.com/photo-1547234935-80c7145ec969?auto=format&fit=crop&w=1200&h=700&q=80",
+ thumb:
+ "https://images.unsplash.com/photo-1547234935-80c7145ec969?auto=format&fit=crop&w=200&h=120&q=60",
+ alt: "Tropical beach with clear water",
+ },
+]
+
+export function FlyingGallery() {
+ return (
+
+
+
+
{
+ const isPrev = activeIndex > index
+
+ return (
+
+
+
+
+
+ )
+ }}
+ />
+
+
+
+
+ Previous
+
+
+
+
+
+ Next
+
+
+
+ (
+
+
+
+ )}
+ />
+
+
+ )
+}
diff --git a/src/flying-gallery/flying-gallery.module.css b/src/flying-gallery/flying-gallery.module.css
new file mode 100644
index 0000000..600b9d5
--- /dev/null
+++ b/src/flying-gallery/flying-gallery.module.css
@@ -0,0 +1,206 @@
+.root {
+ --perspective: 100px;
+ --xoffset: 600px;
+ --yoffset: 200px;
+ --rotation: 16deg;
+ --blur: 2px;
+
+ background: #0a0a0f;
+ color: #fff;
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+ min-height: 100vh;
+ padding: 3rem 0 2rem;
+ overflow: hidden;
+}
+
+.slideArea {
+ position: relative;
+}
+
+/*
+ * The gallery is configured with --item-width: 4px; --gap: 0, so all
+ * elements collapse to near-zero horizontal positions.
+ * All visual positioning is handled here on .slide using CSS vars inherited
+ * from the library-managed :
+ *
+ * --center-offset absolute distance from active (0, 1, 2 …)
+ * --duration transition speed
+ *
+ * className={styles.item} is appended to the library's class list on each .
+ * Since this CSS module is loaded after the library's, same-specificity rules here
+ * win on source order..
+ */
+.item {
+ transform: translateX(0) !important;
+}
+
+[data-side="prev"].item {
+ z-index: calc(20 - var(--center-offset, 0));
+}
+
+.slide {
+ aspect-ratio: 16 / 9;
+ border-radius: 10px;
+ overflow: hidden;
+ position: relative;
+ transition:
+ transform var(--duration, 0.5s),
+ filter var(--duration, 0.5s),
+ opacity var(--duration, 0.5s);
+ width: 100%;
+}
+
+.slide img {
+ display: block;
+ height: 100%;
+ object-fit: cover;
+ width: 100%;
+}
+
+[data-side="active"] .slide {
+ filter: none;
+ opacity: 1;
+ transform: perspective(var(--perspective, 1000px));
+}
+
+[data-side="prev"] .slide {
+ filter: blur(calc(var(--center-offset, 0) * var(--blur, 2px)));
+ opacity: calc(1 - var(--center-offset, 0) * 0.68);
+ transform: perspective(var(--perspective, 1000px))
+ translateX(calc(var(--center-offset, 0) * -1 * var(--xoffset, 600px)))
+ translateY(calc(var(--center-offset, 0) * -1 * var(--yoffset, 200px)))
+ translateZ(calc(var(--center-offset, 0) * 40px))
+ rotateZ(
+ calc(
+ var(--center-offset, 0) * -1 * var(--rotation, 16deg) +
+ sin(calc(var(--i, 0) * 1rad)) * 6deg
+ )
+ );
+}
+
+[data-side="next"] .slide {
+ filter: blur(calc(var(--center-offset, 0) * var(--blur, 2px)));
+ opacity: calc(1 - var(--center-offset, 0) * 0.3);
+ transform: perspective(var(--perspective, 1000px))
+ translateX(calc(var(--center-offset, 0) * var(--xoffset, 600px)))
+ translateY(calc(var(--center-offset, 0) * var(--yoffset, 200px)))
+ translateZ(calc(var(--center-offset, 0) * -80px))
+ rotateZ(
+ calc(
+ var(--center-offset, 0) * var(--rotation, 16deg) +
+ sin(calc(var(--i, 0) * 1.7rad)) * 6deg
+ )
+ );
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .slide {
+ transition: none;
+ }
+}
+
+.navPrev,
+.navNext {
+ align-items: center;
+ appearance: none;
+ background: rgb(255 255 255 / 12%);
+ backdrop-filter: blur(8px);
+ border: 1px solid rgb(255 255 255 / 20%);
+ border-radius: 50%;
+ color: #fff;
+ cursor: pointer;
+ display: flex;
+ height: 3rem;
+ justify-content: center;
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ transition:
+ background 0.2s,
+ border-color 0.2s;
+ width: 3rem;
+ z-index: 1000;
+}
+
+.navPrev:hover,
+.navNext:hover {
+ background: rgb(255 255 255 / 25%);
+ border-color: rgb(255 255 255 / 40%);
+}
+
+.navPrev:disabled,
+.navNext:disabled {
+ opacity: 0.3;
+ pointer-events: none;
+}
+
+.navPrev {
+ left: 1rem;
+}
+
+.navNext {
+ right: 1rem;
+}
+
+.thumbs {
+ display: flex !important;
+ gap: 0.5rem !important;
+ justify-content: center;
+ list-style: none;
+ margin-top: 10px;
+ overflow-x: auto;
+ padding: 0 1.5rem;
+ position: relative;
+ z-index: 10;
+}
+
+.thumbItem {
+ flex-shrink: 0;
+ line-height: 0;
+}
+
+.thumbButton {
+ appearance: none;
+ background: none;
+ border: 2px solid transparent;
+ border-radius: 5px;
+ cursor: pointer;
+ display: block;
+ height: 52px;
+ opacity: 0.45;
+ overflow: hidden;
+ padding: 0;
+ transition:
+ opacity 0.2s,
+ border-color 0.2s;
+ width: 88px;
+}
+
+.thumbButton img {
+ display: block;
+ height: 100%;
+ object-fit: cover;
+ width: 100%;
+}
+
+.thumbButton[aria-current="true"] {
+ border-color: #fff;
+ opacity: 1;
+}
+
+.thumbButton:hover {
+ opacity: 0.8;
+}
+
+.srOnly {
+ border: 0;
+ clip: rect(0 0 0 0);
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ width: 1px;
+}
diff --git a/src/flying-gallery/index.jsx b/src/flying-gallery/index.jsx
new file mode 100644
index 0000000..9bf6b91
--- /dev/null
+++ b/src/flying-gallery/index.jsx
@@ -0,0 +1,12 @@
+import React from "react"
+import ReactDOM from "react-dom/client"
+
+import { HamburgerNav } from "../nav/HamburgerNav"
+import { FlyingGallery } from "./FlyingGallery"
+
+ReactDOM.createRoot(document.getElementById("root")).render(
+
+
+
+
+)
diff --git a/src/main.jsx b/src/main.jsx
index 8e98c53..2fb2e32 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -1,6 +1,7 @@
import React from "react"
import ReactDOM from "react-dom/client"
+import { HamburgerNav } from "./nav/HamburgerNav"
import {
Gallery,
GalleryMain,
@@ -87,6 +88,7 @@ function App() {
ReactDOM.createRoot(document.getElementById("root")).render(
+
)
diff --git a/src/nav/HamburgerNav.jsx b/src/nav/HamburgerNav.jsx
new file mode 100644
index 0000000..7970254
--- /dev/null
+++ b/src/nav/HamburgerNav.jsx
@@ -0,0 +1,59 @@
+import { useState, useEffect, useRef } from "react"
+import styles from "./hamburger-nav.module.css"
+
+const PAGES = [
+ { label: "Basic Gallery", href: "/" },
+ { label: "Flying Gallery", href: "/flying-gallery/" },
+]
+
+export function HamburgerNav() {
+ const [open, setOpen] = useState(false)
+ const navRef = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ const handleKey = (e) => e.key === "Escape" && setOpen(false)
+ const handleClick = (e) => !navRef.current?.contains(e.target) && setOpen(false)
+ document.addEventListener("keydown", handleKey)
+ document.addEventListener("mousedown", handleClick)
+ return () => {
+ document.removeEventListener("keydown", handleKey)
+ document.removeEventListener("mousedown", handleClick)
+ }
+ }, [open])
+
+ return (
+
+ setOpen((v) => !v)}
+ aria-expanded={open}
+ aria-controls="hamburger-menu"
+ aria-label={open ? "Close menu" : "Open menu"}
+ >
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/nav/hamburger-nav.module.css b/src/nav/hamburger-nav.module.css
new file mode 100644
index 0000000..bb4597e
--- /dev/null
+++ b/src/nav/hamburger-nav.module.css
@@ -0,0 +1,100 @@
+.nav {
+ position: fixed;
+ top: 1.25rem;
+ right: 1.25rem;
+ z-index: 100;
+}
+
+.toggle {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 5px;
+ width: 2.5rem;
+ height: 2.5rem;
+ padding: 0.5rem;
+ background: #fff;
+ border: 1px solid #e5e5e5;
+ border-radius: 6px;
+ cursor: pointer;
+ box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
+}
+
+.toggle:focus-visible {
+ outline: 2px solid #333;
+ outline-offset: 2px;
+}
+
+.bar {
+ display: block;
+ width: 100%;
+ height: 2px;
+ background: #333;
+ border-radius: 2px;
+ transform-origin: center;
+ transition:
+ transform 0.2s ease,
+ opacity 0.2s ease;
+}
+
+.barTop {
+ transform: translateY(7px) rotate(45deg);
+}
+
+.barMid {
+ opacity: 0;
+ transform: scaleX(0);
+}
+
+.barBot {
+ transform: translateY(-7px) rotate(-45deg);
+}
+
+.menu {
+ position: absolute;
+ top: calc(100% + 0.5rem);
+ right: 0;
+ min-width: 180px;
+ margin: 0;
+ padding: 0.375rem;
+ list-style: none;
+ background: #fff;
+ border: 1px solid #e5e5e5;
+ border-radius: 8px;
+ box-shadow: 0 4px 16px rgb(0 0 0 / 10%);
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(-6px) scale(0.97);
+ transform-origin: top right;
+ transition:
+ opacity 0.18s ease,
+ transform 0.18s ease;
+}
+
+.menuOpen {
+ opacity: 1;
+ pointer-events: all;
+ transform: translateY(0) scale(1);
+}
+
+.link {
+ display: block;
+ padding: 0.6rem 0.875rem;
+ border-radius: 5px;
+ color: #333;
+ text-decoration: none;
+ font-family: system-ui, sans-serif;
+ font-size: 0.9rem;
+ white-space: nowrap;
+ transition: background 0.12s ease;
+}
+
+.link:hover {
+ background: #f5f5f5;
+}
+
+.link[aria-current="page"] {
+ font-weight: 600;
+ color: #000;
+ background: #f0f0f0;
+}
diff --git a/vite.config.js b/vite.config.js
index ce15f10..1892fc1 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -16,6 +16,12 @@ export default defineConfig({
// make sure to externalize deps that shouldn't be bundled
// into your library
external: ["react", "react-dom"],
+ input: {
+ // eslint-disable-next-line no-undef
+ main: resolve(__dirname, "index.html"),
+ // eslint-disable-next-line no-undef
+ flyingGallery: resolve(__dirname, "flying-gallery/index.html"),
+ },
output: {
// Provide global variables to use in the UMD build
// for externalized deps