Components Media & Portfolio PortfolioPhotographer

PortfolioPhotographer

Editorial photo-portfolio hero.

Live demo

01

Interactive playground

Adjust the live instance below β€” every control rebinds the same hero.

Theme
Landscape Golden hour over the marsh
Landscape Empty harbour, blue hour
Portrait Studio portrait, low key
Urban Concrete and rain
Travel Dune shadow at noon
Monochrome Silver gelatin, grain study
Urban Neon alley, midnight
Studio Still life, study iv
Landscape Pine forest, dawn fog
Fashion Editorial, monochrome
Landscape Long exposure, river dusk
Documentary Documentary, market square
Macro Macro bloom, single stem
Travel Twilight, empty pier

Selected works Β· 2018 β€” 2026

Aria Lindqvist

photographs of light, distance, and quiet rooms

Theme: dark Dots: 24 Drift speed: 100% of default

Try it

  • Hover the reel β€” the drift pauses
  • Tab into a tile β€” drift pauses and the tile lifts
  • Toggle prefers-reduced-motion β€” drift, lens spin and dot pulse stop

Asset-free by default

No external images, no Unsplash, no GSAP. Each tile is a three-stop CSS gradient with an accent vignette. Pass real src values through the photos prop to swap in your own work.

Halton scatter

The dots use the halton(i, 2) / halton(i, 3) low-discrepancy sequence biased toward the centre β€” scattered, not random; no clumping, no grid feel.

Implementation

02
PortfolioPhotographer.svelte
<script>
  import PortfolioPhotographer from
    '$lib/components/PortfolioPhotographer/PortfolioPhotographer.svelte';
  import { SAMPLE_PHOTOS } from
    '$lib/components/PortfolioPhotographer/photos';
</script>
​
<PortfolioPhotographer
  photos={SAMPLE_PHOTOS}
  name="Aria Lindqvist"
  tagline="photographs of light, distance, and quiet rooms"
  years="2018 β€” 2026"
  theme="dark"
/>

PortfolioPhotographer composes a wireframe lens SVG, a marquee-style photo reel, a Halton low-discrepancy dot scatter, and a serif display name into one editorial hero. Each photo tile is a three-stop CSS gradient (asset-free fallback) β€” pass real images through the photos prop to swap in your own work. The reel auto-pauses on hover/focus and respects prefers-reduced-motion.

Logic explainer

03

What Does It Do? (Plain English)

A statement-piece editorial hero for a photographer's landing page. A wireframe camera-lens SVG sits centred behind the scene, slowly rotating; a horizontal reel of "photo" tiles drifts across the foreground in a seamless loop; a Halton-sequence dot scatter converges around the optical centre to suggest a focal plane; and a serif display name plus sans-serif tagline overlay the whole thing.

Think of it as a magazine cover with a slow conveyor belt of work samples behind the title. The reel pauses on hover so visitors can read a tile, the lens spins gently so the page never feels static, and the dots cluster toward the middle so the eye is drawn through the title down into the work. Until M2 ships, every "photo" is a CSS gradient β€” there are no image assets, no fonts to download, and no GSAP. The whole thing is a single <section> with two child sub-trees and one onMount for the reduced-motion query.

How It Works (Pseudo-Code)

state:
  prefersReducedMotion = false
  mounted              = false

derive:
  reelTrack   = [...photos, ...photos]              // duplicate for seamless loop
  scatterDots = Array(dotCount).map(i =>
                  haltonPoint(i)                     // deterministic 2D position
                  .map toward centre with ease 0.7
                  + size 2..5 px
                  + delay (i Γ— 137) mod 1800 ms      // golden-angle stagger
                )

onMount:
  mq = matchMedia('(prefers-reduced-motion: reduce)')
  prefersReducedMotion = mq.matches
  listen for mq change β†’ update prefersReducedMotion
  rAF β†’ mounted = true                              // copy fade-in
  cleanup: remove listener

on hover / focus-within on .prh-reel:
  CSS only β€” animation-play-state: paused

render:
  <section pp-{theme}>
    decorative wireframe lens SVG (rings + cross + 24 tick-marks)
    decorative Halton scatter dots (pulse)
    drifting reel β€” translateX 0% β†’ βˆ’50% over `duration` s, infinite linear
    eyebrow pill + h1 serif name + tagline

The component holds almost no JavaScript state β€” the visual richness comes from CSS animations and a handful of deterministic numerical sequences. Reduced-motion is the only branch that actually requires runtime detection.

The Core Concept: Marquee with translateX(-50%)

A naΓ―ve infinite-scroll marquee animates translateX from 0 to -100% and snaps back, which produces a visible "jump" frame at the loop point. The fix is the duplication trick:

  β”Œβ”€β”€ reel viewport (overflow: hidden) ──┐
  β”‚                                       β”‚
  β”‚  [track-A] [track-A] [track-A] ...    β”‚  ← real photos
  β”‚  [track-B] [track-B] [track-B] ...    β”‚  ← exact copies, aria-hidden
  β”‚  β–²                                    β”‚
  β”‚  └─ animated 0 β†’ βˆ’50% over `duration` β”‚
  β”‚                                       β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The track contains two copies of the photo array end-to-end ([...photos, ...photos]). Animating translateX from 0 to -50% advances the track by exactly one full photo-set width, so the 100% keyframe and the 0% keyframe present visually identical content β€” there is no snap, just a clean continuous loop.

Hover-pause is one CSS rule, no JavaScript:

.prh-reel:hover .prh-reel-track,
.prh-reel:focus-within .prh-reel-track {
  animation-play-state: paused;
}

focus-within is the keyboard-equivalent: tabbing into any tile pauses the drift, giving keyboard users the same affordance as mouse users.

The duplicated tiles in the second half of the track are marked aria-hidden={i >= photos.length} so screen readers enumerate the photo set once, not twice β€” important because to assistive tech the marquee illusion is irrelevant; what matters is the data underneath.

The Halton Sequence: Why Not Math.random()?

The focal scatter wants a pseudo-random-looking dot field that:

  1. Renders identically on server and client (no SSR mismatch).
  2. Looks visually scattered with no clumps or empty patches.
  3. Is parameterised by a single integer i so the layout is reproducible.

Math.random() fails (1) and (2): it produces clumps because each draw is independent, and it can't be made deterministic without a seedable PRNG. The Halton low-discrepancy sequence solves all three. Bases 2 (for X) and 3 (for Y) give a 2D distribution that fills space evenly without any sample being too close to its neighbours.

halton(i, base):
  r = 0; f = 1
  while i > 0:
    f = f / base
    r = r + f Γ— (i mod base)
    i = floor(i / base)
  return r in [0, 1)

Calling haltonPoint(i) returns { x: halton(i+1, 2), y: halton(i+1, 3) } β€” a coordinate in the unit square. The component then nudges every dot toward the centre by a factor of 0.7:

cx = 0.5 + (p.x βˆ’ 0.5) Γ— 0.7
cy = 0.5 + (p.y βˆ’ 0.5) Γ— 0.7

That's a linear ease toward (0.5, 0.5) β€” dots are densest in the middle, sparse at the edges. The animation delay is (i Γ— 137) mod 1800 ms β€” 137 is close to the golden angle in degrees, which spreads the pulse phases evenly so the field never visibly "breathes" in unison.

Because everything derives from i, server and client render byte-identical output and there is no hydration warning.

Performance & Accessibility

  • Asset weight: zero. No images, no font CDN, no animation library. The wireframe lens is inline SVG, tiles are CSS gradients, typography uses ui-serif / ui-sans-serif system stacks.
  • Animation strategy: every motion is CSS-driven β€” prh-drift on the reel, prh-spin on the lens rings (90 s outer, 240 s reverse on inner ticks so they don't lock), prh-pulse on each dot, prh-eyepulse on the eyebrow indicator. JavaScript never touches transform. will-change: transform on the reel track promotes it to a compositor layer for free 60 fps drift.
  • Accessibility: the display name renders as a real <h1>; the reel has aria-label="Drifting photo reel"; duplicated tiles in the second half of the marquee track are aria-hidden; the wireframe lens SVG and Halton scatter are aria-hidden; hover-pause works through focus-within so keyboard users get the same affordance.
  • Reduced motion: detected on mount via matchMedia('(prefers-reduced-motion: reduce)') and reflected as a .prh-instant class that disables every loop. A @media (prefers-reduced-motion: reduce) block also disables the same animations with !important, so the protection is double-layered (no race between mount and first paint).

State Flow Diagram

        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚       INITIAL        β”‚  ── server-render or pre-mount
        β”‚  prefersRM = false   β”‚     copy hidden (8 px ↓)
        β”‚  mounted   = false   β”‚
        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚ onMount: matchMedia read; rAF β†’ mounted = true
                  β–Ό
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚       MOUNTED        β”‚  copy fades + slides in (700 ms)
        β”‚  prefersRM = mq.matchβ”‚  reel begins drift (if !prefersRM)
        β”‚  mounted   = true    β”‚
        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
       β”‚          β”‚                      β”‚
  pointer/focus over reel           mq β†’ 'reduce'
  ─────────────────────             ────────────
  CSS pause via                     all CSS loops halt
  animation-play-state              (.prh-instant class)
       β”‚          β”‚                      β”‚
       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
            hover ends / blur
                  β–Ό
            drift resumes (or stays static if prefersRM)

Props Reference

Prop Type Default Description
photos Photo[] SAMPLE_PHOTOS Array of photo descriptors; defaults to 14 generic gradient tiles.
name string 'Aria Lindqvist' Display name rendered as the <h1>.
tagline string 'photographs of light, distance, and quiet rooms' Sans-serif tagline beneath the name.
years string '2018 β€” 2026' Eyebrow date range shown in the pill above the name.
dotCount number 24 Number of Halton-scatter focal dots.
duration number 36 Seconds per full reel cycle; larger = slower drift.
theme 'light' | 'dark' 'dark' Background palette and text colour.
class string '' Extra classes appended to the root <section>.

The Photo shape:

type Photo = {
  id: string;            // kebab-case, reserved for ?photo= URL sync (M2)
  caption: string;       // editorial one-liner shown in the tile caption
  category: string;      // tag β€” Studio, Portrait, Urban, etc.
  cover: {
    from: string;        // gradient stop 1
    via: string;         // gradient stop 2
    to: string;          // gradient stop 3
    accent: string;      // vignette + focal dot
  };
  src?: string;          // reserved for M2 real-image swap-in
};

Edge Cases

Situation Behaviour
Small viewport (mobile portrait) Tile width is clamp(160px, 18vw, 240px) so tiles shrink with the viewport but stop at 160 px; the reel mask gradient hides tiles partially outside the safe area. <h1> uses clamp(2.5rem, 7vw, 5rem) for fluid type.
Single / empty photos Single: both halves duplicate; loop still appears continuous. Empty: marquee renders nothing; the lens, dots, and copy still render.
Slow network No external assets β€” first paint is bounded by HTML/CSS only. Inline SVG never waits for an image request.
prefers-reduced-motion: reduce Reel drift, lens spin, dot pulse, and eyebrow pulse stop. Hover-pause still works. The one-shot 700 ms copy fade-in still runs.
Keyboard-only user Tabbing into a tile triggers focus-within on the reel, pausing drift via CSS β€” same affordance as mouse hover. <h1> and tagline are real text, so screen readers and search see them in document order.
Dark mode toggle Pass theme="light" for the light palette. The component uses color-mix(in srgb, currentColor, transparent) everywhere, so a parent-set colour flows through.
Server-side rendering All decorative numbers (dot positions, delays) are deterministic via the Halton sequence β€” SSR/CSR render byte-identical, no hydration warning. The reduced-motion query runs only on mount; reduced-motion users may see one quick frame of motion before it stops.
duration={0} CSS treats this as a paused animation; the reel sits at frame zero. Useful for static screenshots.
Long captions Wrap to a second line rather than truncating; tile padding increases rather than overflowing.

Dependencies

  • Svelte 5.x β€” $props, $state, $derived, onMount for the reduced-motion query and the dealcue mount flag.
  • Zero external runtime dependencies β€” no GSAP, no Motion One, no icon library, no font CDN, no images. The wireframe lens is inline SVG, the typography uses ui-serif / ui-sans-serif system stacks, and the photo tiles are pure CSS gradients.

File Structure

src/lib/components/PortfolioPhotographer/PortfolioPhotographer.svelte   # implementation
src/lib/components/PortfolioPhotographer/PhotoReelHero.svelte           # reel + lens + copy subcomponent
src/lib/components/PortfolioPhotographer/types.ts                        # Photo type + Halton helpers
src/lib/components/PortfolioPhotographer/photos.ts                       # SAMPLE_PHOTOS fallback data
src/lib/components/PortfolioPhotographer.md                              # this file (rendered inside ComponentPageShell)
src/lib/components/PortfolioPhotographer.test.ts                         # vitest unit tests
src/routes/portfolio-photographer/+page.svelte                           # demo page

API

04
PropTypeDefaultDescription
photosPhoto[]SAMPLE_PHOTOSArray of photo descriptors (id, gradient stops, optional src). Each descriptor renders as one tile in the drifting reel.
namestring'Aria Lindqvist'Photographer name rendered as the editorial <h1>.
taglinestring'photographs of light, distance, …'Subtitle line beneath the name.
yearsstring'2018 β€” 2026'Date range shown in the eyebrow.
dotCountnumber24Number of Halton-scatter focal dots in the background.
durationnumber36Seconds per full reel-drift cycle.
theme'light' | 'dark''dark'Editorial palette β€” warm dark or paper light.
classstring''Extra classes on the outer <section>.