# Open When
*URL: https://www.grettech.com/components/open-when*

Sealed letters for someone you love: open when you miss me, when you can't sleep, on our anniversary. Break the wax seal and the letter slides out; date-locked ones wait for their day.

- A stack of envelopes, each sealed with wax pressed with your initials
- Click one: the seal breaks, the flap lifts and the letter slides out to read
- Letters on dated days (an anniversary, a birthday) stay sealed until that day and count down to it
- Opened envelopes keep a cracked seal, remembered in that browser
- Each envelope can have its own paper colour; the wax colour is yours to pick
- Fold the letter back with Escape or a click outside; focus returns to its envelope
- Light and dark mode, keyboard and screen-reader friendly, respects reduced motion

## Install with the shadcn CLI
```bash
npx shadcn@latest add https://www.grettech.com/api/registry/open-when
```
With its demo: `npx shadcn@latest add https://www.grettech.com/api/registry/open-when-demo`

**Dependencies:** npm: clsx, tailwind-merge, lucide-react, motion

## Manual installation
**Step 1: Install dependencies**
Install the packages this component uses. It also needs the `cn` helper in `lib/utils.ts`, which `npx shadcn@latest init` adds.

```bash
npm install clsx tailwind-merge lucide-react motion
```

**Step 2: Copy the source code**
Add these files to your project components directory.

File: `components/ui/open-when.tsx`
```tsx
"use client"

import { cn } from "@/lib/utils"
import { Lock, X } from "lucide-react"
import {
  AnimatePresence,
  motion,
  useAnimate,
  useReducedMotion,
} from "motion/react"
import * as React from "react"

export interface OpenWhenLetter {
  /** Finishes "Open when…" (or "Open on…" for a dated letter): "you miss me" */
  when: string
  /** The letter. Line breaks are kept */
  message: string
  /** Sealed until this day (YYYY-MM-DD); before it, the envelope won't open */
  opensOn?: string
  /** The envelope's paper colour */
  color?: string
}

export interface OpenWhenProps extends React.HTMLAttributes<HTMLDivElement> {
  /** The letters, one envelope each */
  letters?: OpenWhenLetter[]
  /** Who they're for, on every envelope and at the top of each letter */
  to?: string
  /** Who they're from, signed under each letter */
  from?: string
  /** Pressed into the wax seal, like initials */
  seal?: string
  /** The wax's colour */
  sealColor?: string
  /** Remember which letters were opened, in this browser */
  remember?: boolean
  /** Called when a letter is opened */
  onOpen?: (letter: OpenWhenLetter, index: number) => void
}

export const DEFAULT_LETTERS: OpenWhenLetter[] = [
  {
    when: "you miss me",
    message:
      "Close your eyes and count to three. That's about how long it takes me to start missing you too.\n\nI'm closer than it feels, and I'm already planning the next time I get to see your face.",
    color: "#f3e7d8",
  },
  {
    when: "you can't sleep",
    message:
      "Put on the playlist I made you, the one that starts with the rain song. Breathe a little slower than the beat.\n\nI'm probably awake too, still thinking about something you said three weeks ago.",
    color: "#e6e9f2",
  },
  {
    when: "you need a laugh",
    message:
      "Remember the waiter who brought us two birthday cakes because I panicked and said yes? You laughed so hard you cried into the tiramisu.\n\nThat's still my favourite sound in the world.",
    color: "#f4e1e1",
  },
  {
    when: "our anniversary",
    message:
      "Another year of you. Thank you for choosing me on the ordinary days, not just the good ones.\n\nLet's keep doing small things together, for a very long time.",
    opensOn: "2027-02-14",
    color: "#e5ede2",
  },
]

const noSubscription = () => () => {}
/** Today as YYYY-MM-DD, in the reader's time zone */
const today = () => new Date().toLocaleDateString("sv-SE")
const daysUntil = (day: string) =>
  Math.ceil(
    (new Date(`${day}T00:00:00`).getTime() -
      new Date(`${today()}T00:00:00`).getTime()) /
      864e5
  )
const readableDate = (day: string) =>
  new Date(`${day}T00:00:00`).toLocaleDateString(undefined, {
    day: "numeric",
    month: "long",
    year: "numeric",
  })

/**
 * Sealed letters for someone you love: "open when you miss me", "open
 * when you can't sleep", "open on our anniversary". Breaking a wax seal
 * lifts the flap and slides the letter out; a dated letter stays sealed
 * until its day, and shakes if opened early.
 */
export const OpenWhen = React.forwardRef<HTMLDivElement, OpenWhenProps>(
  (
    {
      letters = DEFAULT_LETTERS,
      to = "You",
      from = "Me",
      seal = "♥",
      sealColor = "#9b1c31",
      remember = true,
      onOpen,
      className,
      children,
      ...props
    },
    ref
  ) => {
    const reduceMotion = useReducedMotion()
    // Known in the browser only: before that, dated letters stay sealed
    const day = React.useSyncExternalStore(noSubscription, today, () => null)
    const [opened, setOpened] = React.useState<Set<number>>(() => new Set())
    const [opening, setOpening] = React.useState<number | null>(null)
    const [reading, setReading] = React.useState<number | null>(null)
    const [early, setEarly] = React.useState<number | null>(null)
    const buttons = React.useRef<(HTMLButtonElement | null)[]>([])
    const storeKey = (letter: OpenWhenLetter) =>
      `open-when:${to}:${letter.when}`

    // Letters opened before, in this browser
    React.useEffect(() => {
      if (!remember) return
      const seen = new Set<number>()
      letters.forEach((letter, i) => {
        try {
          if (localStorage.getItem(storeKey(letter))) seen.add(i)
        } catch {}
      })
      // Restored after hydration, so the server and client match
      setOpened(seen)
      // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [remember, letters, to])

    const locked = (letter: OpenWhenLetter) =>
      Boolean(letter.opensOn) && (day === null || day < letter.opensOn!)

    const open = (i: number) => {
      const letter = letters[i]
      if (locked(letter)) {
        setEarly(i)
        return
      }
      if (opened.has(i) || reduceMotion) return read(i)
      setOpening(i)
      // Seal, flap, then the letter: about as long as it takes for real
      window.setTimeout(() => read(i), 1100)
    }
    const read = (i: number) => {
      const letter = letters[i]
      setOpening(null)
      setReading(i)
      setOpened((all) => new Set(all).add(i))
      if (remember)
        try {
          localStorage.setItem(storeKey(letter), "1")
        } catch {}
      onOpen?.(letter, i)
    }
    const close = () => {
      const i = reading
      setReading(null)
      if (i !== null) buttons.current[i]?.focus()
    }

    React.useEffect(() => {
      if (early === null) return
      const t = window.setTimeout(() => setEarly(null), 2600)
      return () => window.clearTimeout(t)
    }, [early])

    const letter = reading === null ? null : letters[reading]

    return (
      <div
        ref={ref}
        className={cn(
          "relative isolate w-full overflow-hidden rounded-3xl border bg-[#f6efe6] p-4 text-stone-700 sm:p-10 dark:bg-[#17121a] dark:text-stone-300",
          className
        )}
        {...props}
      >
        {children && (
          <p className="relative z-20 mb-6 text-center font-serif text-xl text-balance text-stone-900 italic sm:mb-8 sm:text-3xl dark:text-stone-100">
            {children}
          </p>
        )}

        <ul className="mx-auto grid max-w-3xl grid-cols-2 gap-x-3 gap-y-9 sm:gap-x-8 sm:gap-y-10">
          {letters.map((item, i) => (
            <li
              key={`${item.when}-${i}`}
              className={cn("relative", opening === i && "z-10")}
            >
              <Envelope
                ref={(node) => {
                  buttons.current[i] = node
                }}
                letter={item}
                to={to}
                seal={seal}
                sealColor={sealColor}
                state={
                  opening === i ? "opening" : opened.has(i) ? "open" : "sealed"
                }
                locked={locked(item)}
                daysLeft={item.opensOn && day ? daysUntil(item.opensOn) : null}
                shake={early === i}
                reduceMotion={Boolean(reduceMotion)}
                onClick={() => open(i)}
              />
              <AnimatePresence>
                {early === i && item.opensOn && (
                  <motion.p
                    role="status"
                    initial={{ opacity: 0, y: -4 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={{ opacity: 0 }}
                    className="absolute inset-x-0 -bottom-7 text-center text-xs text-stone-500 dark:text-stone-400"
                  >
                    Not yet.{" "}
                    {day
                      ? `${daysUntil(item.opensOn).toLocaleString()} ${daysUntil(item.opensOn) === 1 ? "day" : "days"} to go`
                      : ""}
                  </motion.p>
                )}
              </AnimatePresence>
            </li>
          ))}
        </ul>

        <AnimatePresence>
          {letter && (
            <motion.div
              key="reading"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              onClick={close}
              onKeyDown={(e) => e.key === "Escape" && close()}
              className="absolute inset-0 z-50 flex overflow-y-auto bg-stone-900/25 p-4 backdrop-blur-[2px] sm:p-8 dark:bg-black/50"
            >
              <motion.article
                role="dialog"
                aria-modal="true"
                aria-label={`A letter for when ${letter.when}`}
                initial={
                  reduceMotion
                    ? { opacity: 0 }
                    : { opacity: 0, y: 60, rotate: -2, scale: 0.94 }
                }
                animate={{ opacity: 1, y: 0, rotate: 0, scale: 1 }}
                exit={
                  reduceMotion
                    ? { opacity: 0 }
                    : { opacity: 0, y: 40, rotate: 1.5, scale: 0.96 }
                }
                transition={{ type: "spring", stiffness: 220, damping: 26 }}
                onClick={(e) => e.stopPropagation()}
                className="relative m-auto w-full max-w-md rounded-sm bg-[#fffdf8] px-6 pt-8 pb-6 text-stone-700 shadow-[0_30px_60px_-20px_rgb(60_30_20/0.45)] sm:px-10 sm:pt-10 dark:bg-[#211b22] dark:text-stone-300"
                style={{
                  // Ruled like letter paper
                  backgroundImage:
                    "repeating-linear-gradient(transparent 0 27px, rgb(120 90 70 / 0.12) 27px 28px)",
                  backgroundPosition: "0 18px",
                }}
              >
                <button
                  type="button"
                  autoFocus
                  aria-label="Fold the letter back"
                  onClick={close}
                  className="absolute top-3 right-3 rounded-full p-1.5 text-stone-400 transition-colors hover:text-stone-700 focus-visible:ring-2 focus-visible:ring-stone-400 focus-visible:outline-none dark:hover:text-stone-200"
                >
                  <X className="size-4" />
                </button>
                <p className="text-[11px] font-medium tracking-[0.2em] text-stone-400 uppercase">
                  Open {letter.opensOn ? "on" : "when"} {letter.when}
                </p>
                <p className="mt-5 font-serif text-lg italic">Dear {to},</p>
                <p className="mt-3 font-serif text-[17px] leading-7 whitespace-pre-line text-stone-800 dark:text-stone-200">
                  {letter.message}
                </p>
                <p className="mt-6 text-right font-serif text-lg italic">
                  {from}
                </p>
              </motion.article>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    )
  }
)
OpenWhen.displayName = "OpenWhen"

type EnvelopeProps = {
  letter: OpenWhenLetter
  to: string
  seal: string
  sealColor: string
  state: "sealed" | "opening" | "open"
  locked: boolean
  daysLeft: number | null
  shake: boolean
  reduceMotion: boolean
  onClick: () => void
}

/** One envelope: a V flap, a wax seal on its tip, the letter inside */
const Envelope = React.forwardRef<HTMLButtonElement, EnvelopeProps>(
  (
    {
      letter,
      to,
      seal,
      sealColor,
      state,
      locked,
      daysLeft,
      shake,
      reduceMotion,
      onClick,
    },
    ref
  ) => {
    const [scope, animate] = useAnimate()
    const paper = letter.color ?? "#f3e7d8"
    // Up while the letter comes out, then folded back down over it
    const flapOpen = state === "opening"
    const ease = [0.22, 1, 0.36, 1] as const

    React.useEffect(() => {
      if (shake && !reduceMotion && scope.current)
        void animate(
          scope.current,
          { x: [0, -8, 8, -6, 6, -3, 0], rotate: [0, -1, 1, -0.6, 0.6, 0, 0] },
          { duration: 0.5 }
        )
    }, [shake, reduceMotion, animate, scope])

    const label = `Open ${letter.opensOn ? "on" : "when"} ${letter.when}`
    return (
      <motion.button
        ref={ref}
        type="button"
        onClick={onClick}
        aria-label={
          locked && letter.opensOn
            ? // The date is written in the reader's language, so only in the browser
              `${label}. ${daysLeft === null ? "Still sealed" : `Sealed until ${readableDate(letter.opensOn)}`}`
            : label
        }
        whileHover={reduceMotion || state === "opening" ? undefined : { y: -4 }}
        className="group relative block aspect-[6/5] w-full rounded-md text-left [--flap:46%] [perspective:900px] focus-visible:ring-2 focus-visible:ring-stone-400 focus-visible:ring-offset-4 focus-visible:ring-offset-[#f6efe6] focus-visible:outline-none sm:aspect-[7/5] sm:[--flap:56%] dark:focus-visible:ring-offset-[#17121a]"
      >
        {/* Shaken when it's opened too early */}
        <span ref={scope} className="absolute inset-0">
          {/* The back of the envelope */}
          <span
            className="absolute inset-0 rounded-md shadow-[0_14px_30px_-14px_rgb(70_40_20/0.45)] dark:shadow-[0_14px_30px_-14px_rgb(0_0_0/0.7)]"
            style={{ background: paper, filter: "brightness(0.93)" }}
          />

          {/* The letter inside, rising out as it's opened */}
          <motion.span
            className="absolute inset-x-[7%] top-[8%] bottom-[6%] rounded-sm bg-[#fffdf8] shadow-sm dark:bg-[#2a232b]"
            style={{
              backgroundImage:
                "repeating-linear-gradient(transparent 0 9px, rgb(120 90 70 / 0.14) 9px 10px)",
            }}
            initial={false}
            animate={{
              y: state === "opening" ? "-38%" : "0%",
            }}
            transition={{
              duration: 0.5,
              delay: state === "opening" ? 0.55 : 0,
              ease,
            }}
          />

          {/* The front pocket, with the V where the flap meets it */}
          <span
            className="absolute inset-0 rounded-md"
            style={{
              background: paper,
              clipPath:
                "polygon(0 0, 50% calc(var(--flap) - 2%), 100% 0, 100% 100%, 0 100%)",
            }}
          >
            <span
              className="absolute inset-0"
              style={{
                background:
                  "linear-gradient(to top right, transparent 49.6%, rgb(0 0 0 / 0.06) 50%, transparent 50.4%), linear-gradient(to top left, transparent 49.6%, rgb(0 0 0 / 0.06) 50%, transparent 50.4%)",
              }}
            />
          </span>

          {/* The flap: closed over the letter, then swung up behind it */}
          <motion.span
            className="absolute inset-x-0 top-0 h-(--flap) origin-top rounded-t-md"
            style={{
              background: paper,
              clipPath: "polygon(0 0, 100% 0, 50% 100%)",
              filter: "brightness(0.97)",
              zIndex: flapOpen ? 0 : 20,
              backfaceVisibility: "visible",
            }}
            initial={false}
            animate={{ rotateX: flapOpen ? 180 : 0 }}
            transition={{
              duration: reduceMotion ? 0 : 0.45,
              delay: state === "opening" ? 0.25 : 0,
              ease,
            }}
          />

          {/* The label, written on the front */}
          <span className="absolute inset-x-0 bottom-[9%] z-10 flex flex-col items-center gap-0.5 px-2 text-center sm:bottom-[11%] sm:gap-1 sm:px-4">
            <span className="font-serif text-[13px] leading-tight text-balance text-stone-800 italic sm:text-base">
              {label}
            </span>
            <span className="text-[9px] font-medium tracking-[0.14em] text-balance text-stone-500 uppercase sm:text-[10px] sm:tracking-[0.18em]">
              {locked && daysLeft !== null && daysLeft > 0
                ? `For ${to} · in ${daysLeft.toLocaleString()} ${daysLeft === 1 ? "day" : "days"}`
                : `For ${to}`}
            </span>
          </span>

          {/* The wax seal on the flap's tip: it breaks in two as the
              envelope opens, and stays cracked once it has been */}
          <span className="absolute top-(--flap) left-1/2 z-30 size-9 -translate-x-1/2 -translate-y-1/2 sm:size-12">
            {[0, 1].map((half) => (
              <motion.span
                key={half}
                className="absolute inset-0 grid place-items-center rounded-full font-serif text-[11px] font-semibold text-white/80 italic sm:text-sm"
                style={{
                  background: `radial-gradient(circle at 34% 30%, color-mix(in srgb, ${sealColor} 70%, white), ${sealColor} 55%, color-mix(in srgb, ${sealColor} 75%, black))`,
                  boxShadow:
                    "0 2px 4px rgb(0 0 0 / 0.3), inset 0 -2px 3px rgb(0 0 0 / 0.25), inset 0 2px 2px rgb(255 255 255 / 0.15)",
                  clipPath: half
                    ? "polygon(52% 0, 100% 0, 100% 100%, 46% 100%, 55% 60%, 47% 40%)"
                    : "polygon(0 0, 52% 0, 47% 40%, 55% 60%, 46% 100%, 0 100%)",
                  filter: locked ? "saturate(0.55)" : undefined,
                }}
                initial={false}
                animate={
                  state === "opening"
                    ? {
                        x: half ? 14 : -14,
                        y: 10,
                        rotate: half ? 24 : -24,
                        opacity: 0,
                      }
                    : state === "open"
                      ? {
                          x: half ? 3 : -3,
                          y: half ? 1 : 0,
                          rotate: half ? 9 : -7,
                          opacity: 1,
                        }
                      : { x: 0, y: 0, rotate: 0, opacity: 1 }
                }
                transition={{ duration: 0.4, ease }}
              >
                {locked ? (
                  <Lock className="size-3.5 text-white/75 sm:size-4" />
                ) : (
                  seal
                )}
              </motion.span>
            ))}
          </span>
        </span>
      </motion.button>
    )
  }
)
Envelope.displayName = "Envelope"

```

**Step 3: Framework Adjustments (Optional)**
If you are manually copying this code into a Vite or Remix project (without using the CLI), make sure to remove the `"use client";` directive from the very top of the file.

## Props
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
| `letters` | `OpenWhenLetter[]` | `DEFAULT_LETTERS` | No | The letters, one envelope each |
| `letters.when` | `string` | - | Yes | Finishes "Open when…" (or "Open on…" for a dated letter): "you miss me" |
| `letters.message` | `string` | - | Yes | The letter. Line breaks are kept |
| `letters.opensOn` | `string` | - | No | Sealed until this day (YYYY-MM-DD); before it, the envelope won't open |
| `letters.color` | `string` | - | No | The envelope's paper colour |
| `to` | `string` | `"You"` | No | Who they're for, on every envelope and at the top of each letter |
| `from` | `string` | `"Me"` | No | Who they're from, signed under each letter |
| `seal` | `string` | `"♥"` | No | Pressed into the wax seal, like initials |
| `sealColor` | `string` | `"#9b1c31"` | No | The wax's colour |
| `remember` | `boolean` | `true` | No | Remember which letters were opened, in this browser |
| `onOpen` | `(letter: OpenWhenLetter, index: number) => void` | - | No | Called when a letter is opened |

## Usage
```tsx
"use client"

import { OpenWhen } from "@/components/ui/open-when"

export default function OpenWhenDemo() {
  return (
    <OpenWhen
      to="Mira"
      from="Aarav"
      seal="A·M"
      letters={[
        {
          when: "you miss me",
          message:
            "Close your eyes and count to three. That's about how long it takes me to start missing you too.\n\nI'm closer than it feels, and I'm already planning the next time I get to see your face.",
          color: "#f3e7d8",
        },
        {
          when: "you can't sleep",
          message:
            "Put on the playlist I made you, the one that starts with the rain song. Breathe a little slower than the beat.\n\nI'm probably awake too, still thinking about something you said three weeks ago.",
          color: "#e6e9f2",
        },
        {
          when: "you need a laugh",
          message:
            "Remember the waiter who brought us two birthday cakes because I panicked and said yes? You laughed so hard you cried into the tiramisu.\n\nThat's still my favourite sound in the world.",
          color: "#f4e1e1",
        },
        {
          when: "our anniversary",
          message:
            "Another year of you. Thank you for choosing me on the ordinary days, not just the good ones.\n\nLet's keep doing small things together, for a very long time.",
          opensOn: "2027-02-14",
          color: "#e5ede2",
        },
      ]}
    >
      For the days I can't be there
    </OpenWhen>
  )
}

```
