diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx
new file mode 100644
index 0000000..37871f9
--- /dev/null
+++ b/frontend/src/components/gallery/GalleryLayout.tsx
@@ -0,0 +1,46 @@
+// Layout shell shared by the public gallery page (GalleryClient) and its
+// loading placeholder (GallerySkeleton). Column count, gaps, radii and
+// container padding are defined once here, so the skeleton's geometry can
+// never drift from the grid it stands in for.
+
+export function GalleryHeroFrame({
+ children,
+ backdrop,
+}: {
+ children: React.ReactNode;
+ /** Cover image + scrim, absolutely positioned behind the text. */
+ backdrop?: React.ReactNode;
+}) {
+ return (
+
+ {backdrop}
+
{children}
+
+ );
+}
+
+export function GalleryContainer({ children }: { children: React.ReactNode }) {
+ return
{children}
;
+}
+
+export function MasonryGrid({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// Per-tile geometry. Deliberately carries no background so callers can pick
+// one (photo tiles: bg-gray-100, skeleton tiles: the skeleton surface) without
+// two `bg-*` utilities fighting over CSS order.
+export const masonryTileClass = 'mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl';
+
+/**
+ * Reserves the tile's aspect ratio up front so the image can load into a box
+ * that is already the right size — late arrivals never reflow the columns.
+ * Returns undefined when the photo has no stored dimensions.
+ */
+export function aspectStyle(width?: number, height?: number): React.CSSProperties | undefined {
+ return width && height ? { aspectRatio: `${width} / ${height}` } : undefined;
+}
diff --git a/frontend/src/components/gallery/GallerySkeleton.tsx b/frontend/src/components/gallery/GallerySkeleton.tsx
new file mode 100644
index 0000000..60eccc4
--- /dev/null
+++ b/frontend/src/components/gallery/GallerySkeleton.tsx
@@ -0,0 +1,59 @@
+import clsx from 'clsx';
+import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton';
+import { GalleryContainer, GalleryHeroFrame, MasonryGrid, masonryTileClass } from './GalleryLayout';
+
+// Loading placeholder for the public gallery page. It renders through the same
+// hero frame, container and masonry grid as the real page (see GalleryLayout),
+// so replacing it with photos changes only the pixels inside the tiles.
+
+// Portrait/landscape/square mix standing in for a real event set. Fixed order
+// on purpose — a random shuffle would differ between the server and client
+// render and blow up hydration.
+const FALLBACK_RATIOS = [3 / 4, 4 / 3, 1, 2 / 3, 3 / 2, 4 / 5, 1, 3 / 4, 16 / 9, 4 / 5, 3 / 4, 4 / 3];
+
+interface GallerySkeletonProps {
+ /** Number of tiles to draw; ignored when `ratios` is given. */
+ count?: number;
+ /**
+ * Real width/height ratios when the caller already knows them (e.g. a
+ * cached gallery payload). Produces a grid with exactly the right geometry
+ * instead of the guessed mix above.
+ */
+ ratios?: number[];
+}
+
+export default function GallerySkeleton({ count = 12, ratios }: GallerySkeletonProps) {
+ const tiles =
+ ratios && ratios.length > 0
+ ? ratios
+ : Array.from({ length: count }, (_, i) => FALLBACK_RATIOS[i % FALLBACK_RATIOS.length]);
+
+ return (
+
+
+ {/* Matches the h1 (text-3xl / md:text-5xl) and the pill row below it. */}
+
+
+
+
+
+
+
+
+
+ {tiles.map((ratio, i) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/gallery/PhotoTile.tsx b/frontend/src/components/gallery/PhotoTile.tsx
new file mode 100644
index 0000000..0dad9b6
--- /dev/null
+++ b/frontend/src/components/gallery/PhotoTile.tsx
@@ -0,0 +1,100 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import clsx from 'clsx';
+import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
+import type { Photo } from '@/lib/api';
+import Spinner from '@/components/ui/Spinner';
+import { aspectStyle, masonryTileClass } from './GalleryLayout';
+
+interface PhotoTileProps {
+ photo: Photo;
+ /** Above-the-fold tiles load eagerly, the rest lazily. */
+ eager?: boolean;
+ onOpen: () => void;
+ onDownload: () => void;
+ downloading: boolean;
+ labels: { download: string; downloading: string };
+}
+
+export default function PhotoTile({
+ photo,
+ eager,
+ onOpen,
+ onDownload,
+ downloading,
+ labels,
+}: PhotoTileProps) {
+ const imgRef = useRef(null);
+ // 'initial' is what the server renders: fully visible, so a public gallery
+ // still paints its photos if hydration is slow or JS never arrives. The
+ // fade only takes over once we're mounted and know the image is still in
+ // flight — a cached image reports `complete` here and skips it entirely
+ // (its onLoad already fired before React attached the handler).
+ const [state, setState] = useState<'initial' | 'pending' | 'loaded'>('initial');
+ const pending = state === 'pending';
+
+ useEffect(() => {
+ setState(imgRef.current?.complete ? 'loaded' : 'pending');
+ }, []);
+
+ return (
+