Skip to content

1440 × 900

Vitrine

A developer's portfolio as a dark room lit only by the work — each project's screen throws its own measured light, and the page takes its colour from whichever is lit.

A developer's portfolio as a dark room with no lamps in it: the only light on the page comes from the work. Each project is a screen; the one you've scrolled to is lit, and its light falls on the floor below it in the work's own colour — coral from Nocturne's rings, blue from Halcyon's horizon — while the others rest dim. That colour is the page's one accent too, on the mark in the nav and the lit project's number. None of it is chosen: a small script measures each screenshot's light the way Iris's catalogue measures every piece. It ends on an empty frame, numbered like the next project, that wakes to plain white: the visitor's.

Everything else is the category's standard order in one typeface and three strengths of white — nav, hero, three projects, about with one quote, questions, the close, footer — with a lot of dark between them. Only light moves, and only with the scroll: CSS scroll-driven animations on opacity, no WebGL and no scroll capture; under reduced motion, or where scroll-driven animations aren't supported, every screen is lit. Make it yours in work.ts: add your screenshots (a desktop and a phone capture of each project — they aren't part of the code), run measure.mjs, and the page relights in your work's colours. The work shown is three of Iris's own templates, running; the person, the dates, the prices and the quote are placeholder copy — replace them, or remove the quote.

Details

Kind
Portfolio
Stack
React · TypeScript
Depends on
Nothing
Licence
Free
Added
29 Sep 2026
Tags
portfolio, developer, scroll, measured-light, no-webgl, faq

Its light

  • 1%
  • 15%
  • 71%
  • 9%
  • 4%

Code

Install

No installation needed — self-contained, paste-in code.

Usage

example.tsx
import { VitrineLanding } from "./vitrine/VitrineLanding";

// The whole page. Put your screenshots where work.ts points (a desktop and a
// phone capture of each project), run measure.mjs on them so the page takes
// their light, then drop it into a route.
export default function Page() {
  return <VitrineLanding />;
}

Component

The real source, exactly as it ships — multiple files, kept together.

import type { CSSProperties, ReactNode } from "react";
import { ABOUT, CLOSE, PERSON, QUESTIONS, WORK, type Project } from "./work";
import { CopyEmail, VitrineLight } from "./VitrineLight";
import "./vitrine.css";

/**
 * Vitrine — a developer's portfolio, from Iris's Templates.
 *
 * A dark room with no lamps in it: the only light on the page comes from
 * the work. Each project is a screen; the one at the middle of the view is
 * lit and its light falls on the floor below it, in the work's own colour,
 * while the others rest dim. The page's one accent is that colour. It ends
 * on an empty frame, numbered like the next project, that wakes to plain
 * white — the visitor's.
 *
 * None of that light is chosen: `measure.mjs` reads it from each
 * screenshot into `light.json`. Replace the screenshots and the copy in
 * `work.ts`, run the script, and the page relights in your work's colours.
 *
 * The structure is the category's own — nav, hero, work, about, questions,
 * close, footer — so it's used without being learned. Text never animates;
 * only light does. The light follows the scroll with CSS scroll-driven
 * animations (opacity, on the compositor); `VitrineLight` wakes the first
 * screen once its picture is in and hands the accent from room to room.
 * Without it, or under reduced motion, every screen is lit.
 */

const NAV = [
  { label: "Work", href: "#work" },
  { label: "About", href: "#about" },
  { label: "Questions", href: "#questions" },
];

const mailto = `mailto:${PERSON.email}?subject=${encodeURIComponent("A project")}`;
const pad = (n: number) => String(n).padStart(2, "0");

/** A tiny screen, filled with the light of whichever work is lit. */
function Mark() {
  return <span aria-hidden="true" className="vt-mark" />;
}

function Primary({ children, small = false }: { children: ReactNode; small?: boolean }) {
  const size = small ? "h-9 px-4 text-[0.875rem]" : "h-11 px-5 text-[0.9375rem]";
  return (
    <a href={mailto} className={`vt-primary inline-flex shrink-0 items-center rounded-full font-medium ${size}`}>
      {children}
    </a>
  );
}

/* ---- nav ------------------------------------------------------------ */

/* No phone menu: it's one page, read top to bottom, and its one action is
   in the bar at every width. */
function Nav() {
  return (
    <header className="vt-nav fixed inset-x-0 top-0 z-30">
      <nav aria-label="Main" className="vt-wrap flex h-16 items-center justify-between gap-4">
        <a href="#top" className="vt-brand flex items-center gap-3 text-[1.0625rem] font-semibold tracking-[-0.01em] whitespace-nowrap">
          <Mark />
          {PERSON.name}
        </a>
        <ul className="hidden items-center gap-9 text-[0.9375rem] text-[color:var(--vt-dim)] md:flex">
          {NAV.map((n) => (
            <li key={n.href}>
              <a href={n.href} className="vt-link">
                {n.label}
              </a>
            </li>
          ))}
        </ul>
        <Primary small>Start a project</Primary>
      </nav>
    </header>
  );
}

/* ---- hero ------------------------------------------------------------ */

/* Two columns when either the column is wide enough or the window is — the
   column narrows with the window's height, and a short laptop window
   shouldn't lose the split. */
function Hero() {
  return (
    <section id="top" aria-labelledby="vt-title" className="vt-wrap @container pt-[clamp(6.5rem,14vh,8.5rem)]">
      <div className="grid grid-cols-1 gap-x-10 gap-y-6 @3xl:grid-cols-12 @3xl:items-end lg:grid-cols-12 lg:items-end">
        <h1
          id="vt-title"
          className="text-[clamp(2.75rem,9cqi,5.25rem)] leading-[0.95] font-semibold tracking-[-0.045em] text-balance @3xl:col-span-7 @3xl:text-[clamp(3rem,8.4cqi,5.25rem)] lg:col-span-7 lg:text-[clamp(3rem,8.4cqi,5.25rem)]"
        >
          Screens worth leaving on.
        </h1>
        <div className="@3xl:col-span-5 @3xl:pb-1.5 lg:col-span-5 lg:pb-1.5">
          <p className="max-w-[36ch] text-[1.0625rem] leading-[1.5] text-pretty text-[color:var(--vt-dim)]">
            I’m {PERSON.name}, a developer in Lisbon. I design and build websites and product interfaces, end to end.
          </p>
          <div className="mt-6">
            <Primary>Start a project</Primary>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ---- the work ---------------------------------------------------------- */

/** A work's light, as its room reads it: the colour, and how much of it. */
function lightVars(light: Project["light"]) {
  return {
    "--vt-accent": light.accent,
    // A bright page lights the room more than a dark one.
    "--vt-level": (0.3 + 0.45 * light.level).toFixed(3),
  } as CSSProperties;
}

/** Which work, what it is, what it's made with — a label, so the screen is what's looked at. */
function Caption({
  n,
  name,
  title,
  titleId,
  line,
  meta,
  after,
  heading: Heading = "h3",
}: {
  n: number;
  name?: string;
  title: string;
  titleId: string;
  line: string;
  meta?: ReactNode;
  after?: ReactNode;
  heading?: "h2" | "h3";
}) {
  return (
    <div className="@container">
      <div className="grid grid-cols-1 gap-x-10 gap-y-3 @3xl:grid-cols-12 @3xl:items-end lg:grid-cols-12 lg:items-end">
        <div className="@3xl:col-span-5 lg:col-span-5">
          <p className="flex items-baseline gap-2.5 text-[0.875rem] font-medium">
            <span className="vt-num tabular-nums">{pad(n)}</span>
            {name && <span className="text-[color:var(--vt-dim)]">{name}</span>}
          </p>
          <Heading id={titleId} className="mt-1.5 text-[clamp(1.375rem,1.2rem+0.6vw,1.625rem)] leading-[1.15] font-semibold tracking-[-0.02em] text-balance">
            {title}
          </Heading>
        </div>
        <div className="@3xl:col-span-7 lg:col-span-7">
          <p className="max-w-[52ch] text-[0.9375rem] leading-[1.55] text-pretty text-[color:var(--vt-dim)]">{line}</p>
          {meta && <p className="mt-1.5 flex flex-wrap items-baseline gap-x-5 gap-y-1 text-[0.8125rem] text-[color:var(--vt-faint)]">{meta}</p>}
          {after}
        </div>
      </div>
    </div>
  );
}

function Picture({ p, first }: { p: Project; first: boolean }) {
  const src = (variant: string, ext: string) => `${p.image}${variant}.${ext}`;
  const phone = (ext: string) => `${src(".phone-720", ext)} 720w, ${src(".phone-1080", ext)} 1080w`;
  const desk = (ext: string) => `${src("-1120", ext)} 1120w, ${src("-2240", ext)} 2240w`;
  // The column's own width (vitrine.css, --vt-col), so a laptop at 1× loads the 1120.
  const sizes = "min(75rem, calc(100vw - 6rem), max(40rem, 99vh))";
  return (
    <picture>
      <source media="(max-width: 639px)" type="image/avif" srcSet={phone("avif")} sizes="calc(100vw - 2.5rem)" />
      <source media="(max-width: 639px)" type="image/webp" srcSet={phone("webp")} sizes="calc(100vw - 2.5rem)" />
      <source type="image/avif" srcSet={desk("avif")} sizes={sizes} />
      <img
        src={src("-1120", "webp")}
        srcSet={desk("webp")}
        sizes={sizes}
        alt={p.alt}
        width={1120}
        height={700}
        loading={first ? "eager" : "lazy"}
        fetchPriority={first ? "high" : "auto"}
        decoding="async"
      />
    </picture>
  );
}

/** A screen's light on the floor below it, and the screen. */
function Stage({ children }: { children: ReactNode }) {
  return (
    <div className="vt-stage">
      <span aria-hidden="true" className="vt-spill-wake">
        <span className="vt-spill" />
      </span>
      {children}
    </div>
  );
}

function Room({ p, n }: { p: Project; n: number }) {
  const id = `vt-work-${p.key}`;
  const first = n === 1;
  return (
    <article aria-labelledby={id} data-room data-accent={p.light.accent} data-first={first || undefined} className="vt-room" style={lightVars(p.light)}>
      <div className="vt-wrap">
        <Caption
          n={n}
          name={p.name}
          title={p.title}
          titleId={id}
          line={p.line}
          meta={
            <>
              <span>{p.meta}</span>
              {/* For the pointer; the screen below is the one link a keyboard or screen reader meets. */}
              <a href={p.href} target="_blank" rel="noopener" aria-hidden="true" tabIndex={-1} className="vt-link text-[color:var(--vt-fg)]">
                Open the site <span aria-hidden="true">↗</span>
              </a>
            </>
          }
        />
        <Stage>
          <a href={p.href} target="_blank" rel="noopener" className="vt-screen" data-screen>
            <span className="sr-only">Open the site: {p.name} (opens in a new tab).</span>
            <span className="vt-glass">
              <Picture p={p} first={first} />
              <span aria-hidden="true" className="vt-dim" />
              {first && <span aria-hidden="true" className="vt-wake" />}
            </span>
          </a>
        </Stage>
      </div>
    </article>
  );
}

/* ---- about, questions -------------------------------------------------- */

function About() {
  const [claim, method, proof] = ABOUT.statement;
  return (
    <section id="about" aria-labelledby="vt-about" className="vt-wrap vt-section @container">
      <h2 id="vt-about" className="text-[0.9375rem] font-medium text-[color:var(--vt-faint)]">
        About
      </h2>
      {/* One statement read in three strengths: the claim, how, the proof. */}
      <p className="mt-6 max-w-[26ch] text-[clamp(1.875rem,1.1rem+2.6vw,3.25rem)] leading-[1.12] font-semibold tracking-[-0.03em] text-balance">
        {claim} <span className="text-[color:var(--vt-dim)]">{method}</span>{" "}
        <span className="text-[color:var(--vt-faint)]">{proof}</span>
      </p>
      <figure className="vt-rule mt-[clamp(4rem,9vw,6.5rem)] grid grid-cols-1 gap-x-10 gap-y-5 pt-8 @3xl:grid-cols-12">
        <blockquote className="text-[clamp(1.25rem,1.05rem+0.7vw,1.625rem)] leading-[1.4] font-medium tracking-[-0.015em] text-pretty @3xl:col-span-8">
          <p>“{ABOUT.quote.text}”</p>
        </blockquote>
        <figcaption className="text-[0.9375rem] leading-[1.5] @3xl:col-span-4 @3xl:pt-1.5">
          {ABOUT.quote.who}
          <span className="block text-[color:var(--vt-faint)]">{ABOUT.quote.what}</span>
        </figcaption>
      </figure>
    </section>
  );
}

function Questions() {
  return (
    <section id="questions" aria-labelledby="vt-questions" className="vt-wrap vt-section @container">
      <div className="grid grid-cols-1 gap-x-10 gap-y-10 @3xl:grid-cols-12">
        <div className="@3xl:col-span-4">
          <p className="text-[0.9375rem] font-medium text-[color:var(--vt-faint)]">Questions</p>
          <h2 id="vt-questions" className="mt-4 text-[clamp(1.875rem,1.3rem+1.9vw,2.75rem)] leading-[1.04] font-semibold tracking-[-0.03em]">
            Before you write.
          </h2>
        </div>
        <div className="@3xl:col-span-8">
          {QUESTIONS.map(([q, a]) => (
            <details key={q} className="vt-q group">
              <summary className="flex cursor-pointer items-center justify-between gap-6 py-5 text-[1.0625rem] font-medium">
                {q}
                <svg viewBox="0 0 16 16" aria-hidden="true" className="size-4 shrink-0 text-[color:var(--vt-dim)]">
                  <path d="M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
                  <path d="M8 3v10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="group-open:hidden" />
                </svg>
              </summary>
              <p className="max-w-[58ch] pb-6 text-[1rem] leading-[1.6] text-pretty text-[color:var(--vt-dim)]">{a}</p>
            </details>
          ))}
        </div>
      </div>
    </section>
  );
}

/* ---- close, footer -------------------------------------------------------- */

/** The next frame in the sequence: empty, white when it's lit, and the visitor's. */
function Close() {
  const light = { accent: "#f2f2f2", level: 0.3 };
  return (
    <section aria-labelledby="vt-close" data-room data-accent={light.accent} className="vt-room vt-close" style={lightVars(light)}>
      <div className="vt-wrap">
        <Caption
          n={WORK.length + 1}
          title="Your project."
          titleId="vt-close"
          line={CLOSE.line}
          heading="h2"
          after={
            <div className="mt-5 flex flex-wrap items-center gap-x-4 gap-y-3">
              <Primary>Start a project</Primary>
              <CopyEmail email={PERSON.email} />
            </div>
          }
        />
        <Stage>
          <div className="vt-screen" data-screen>
            <div className="vt-glass vt-empty">
              <span aria-hidden="true" className="vt-dim" />
            </div>
          </div>
        </Stage>
      </div>
    </section>
  );
}

function Footer() {
  return (
    <footer className="vt-wrap pb-10">
      <div className="vt-rule flex flex-col gap-6 pt-8 text-[0.875rem] md:flex-row md:items-baseline md:justify-between">
        <p>
          <span className="font-medium">{PERSON.name}</span>
          <span className="text-[color:var(--vt-faint)]"> · {PERSON.role}</span>
        </p>
        <ul className="flex flex-wrap gap-x-6 gap-y-2 text-[color:var(--vt-dim)]">
          {PERSON.links.map((l) => (
            <li key={l.label}>
              <a href={l.href} className="vt-link" rel="me noopener">
                {l.label}
              </a>
            </li>
          ))}
        </ul>
        <p className="text-[color:var(--vt-faint)]">© 2026</p>
      </div>
      <p className="mt-6 text-[0.8125rem] leading-[1.55] text-[color:var(--vt-faint)]">
        The light around each project is measured from its own screenshot.
      </p>
    </footer>
  );
}

export function VitrineLanding() {
  const [first] = WORK;
  return (
    <div className="vitrine" data-vitrine style={{ "--vt-light-first": first.light.accent } as CSSProperties}>
      <a href="#work" className="vt-skip">
        Skip to the work
      </a>
      <Nav />
      <main>
        <Hero />
        <section id="work" aria-label="Work">
          <h2 className="sr-only">Selected work</h2>
          {WORK.map((p, i) => (
            <Room key={p.key} p={p} n={i + 1} />
          ))}
        </section>
        <About />
        <Questions />
        <Close />
      </main>
      <Footer />
      <VitrineLight />
    </div>
  );
}

Nearest in light

Start with a background.

The backgrounds are the most finished corner of the catalogue. Take one and drop it under your own copy.

Browse backgrounds