# Landing Page v3

> Coming-soon countdown — a wide Solari split-flap board for Acme 2.0 where the headline, name, and live days/hours/minutes/seconds all live inside the grid as a single hero section. Flaps track the host light/dark tokens and blank cells recede into the page so the board sits without a bezel, with mechanical flips on power-up and every second as digits change. New projects only (shadcn init --template next).

A shadcn/ui block from the [@uiception](https://uiception.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uiception/landing-page-v3).

- Page, with a live preview: https://shadcn.exchange/r/uiception/landing-page-v3
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uiception/landing-page-v3-light-91f43fed1ed2bbf2.png
- Registry item: https://uiception.com/r/landing-page-v3.json
- Categories: template, landing-pages
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uiception.com/r/landing-page-v3.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `geist`

## Notes from the registry

New Next.js project only (same flow as https://ui.shadcn.com/create):
  npx shadcn@latest init --template next -y "https://uiception.com/r/landing-page-v3.json"

This scaffolds Next.js + shadcn, then installs the full site.

Edit the headline, name, unit labels, and countdown targetDate in lib/site.ts — every field renders as flaps on a fixed 23-module board, nothing floats outside it. The headline is the alignment origin; the name and each countdown row (number, empty flap, label) hang off the same word-gap column (see lib/board.ts). Flaps mix from the host light/dark tokens; blank cells recede so the board needs no bezel. Anatomy in styles/solari.css.

Preview at /view/landing-page-v3.

## Files

### `registry/new-york/templates/landing-page-v3/app/layout.tsx`

```tsx
import type { Metadata } from "next"
import { GeistMono } from "geist/font/mono"
import { GeistSans } from "geist/font/sans"
import type { ReactNode } from "react"

import { site } from "../lib/site"

import "./globals.css"

export const metadata: Metadata = {
  title: `${site.headline} ${site.name}`,
  description: `${site.headline} ${site.name}`,
}

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html
      lang="en"
      className={`${GeistSans.variable} ${GeistMono.variable} h-full antialiased`}
      suppressHydrationWarning
    >
      <body className="flex min-h-svh flex-col bg-background font-sans text-foreground">
        {children}
      </body>
    </html>
  )
}
```

### `registry/new-york/templates/landing-page-v3/app/page.tsx`

```tsx
import { CountdownHero } from "../components/countdown-hero"

export default function HomePage() {
  return (
    <main>
      <CountdownHero />
    </main>
  )
}
```

### `registry/new-york/templates/landing-page-v3/styles/motion.css`

```css
/* landing-page-v3 motion.
 *
 * Theme tokens (:root / .dark / @theme) come from the shadcn starter's
 * app/globals.css — this file only carries the hero entrance animation,
 * scoped to its own class so it is safe to import anywhere (the component
 * that uses it, and the host preview route).
 *
 * Imported by components/countdown-hero.tsx.
 */

@keyframes hero-reveal {
  from {
    opacity: 0;
    filter: blur(10px);
    transform: translateY(14px);
  }
  to {
    opacity: 1;
    filter: blur(0);
    transform: translateY(0);
  }
}

.hero-reveal {
  opacity: 0;
  animation: hero-reveal 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
  animation-delay: var(--hero-delay, 0ms);
}

@media (prefers-reduced-motion: reduce) {
  .hero-reveal {
    opacity: 1;
    animation: none;
    filter: none;
    transform: none;
  }
}
```

### `registry/new-york/templates/landing-page-v3/components/countdown-hero.tsx`

```tsx
"use client"

import {
  useEffect,
  useMemo,
  useRef,
  useState,
  useSyncExternalStore,
  type CSSProperties,
} from "react"

import {
  activeUnitKeys,
  BOARD_COLS,
  layoutBoard,
  UNIT_KEYS,
  unitValue,
  type UnitKey,
} from "../lib/board"
import { remainingParts, type CountdownParts } from "../lib/countdown"
import { site } from "../lib/site"
import { SolariBoard } from "./solari-board"
import { SOLARI_FLIP_MS } from "./solari-cell"

import "../styles/motion.css"

const SPIN_GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
const ZERO_PARTS: CountdownParts = { days: 0, hours: 0, minutes: 0, seconds: 0 }

const SHUFFLE_STEPS = 4
const STAGGER_MS = 4
const UNIT_FLAP_STAGGER_MS = 42

const emptySubscribe = () => () => {}

function useHydrated() {
  return useSyncExternalStore(emptySubscribe, () => true, () => false)
}

function useReducedMotion() {
  return useSyncExternalStore(
    emptySubscribe,
    () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
    () => false
  )
}

function randomGlyph(exclude: string): string {
  let glyph = SPIN_GLYPHS[Math.floor(Math.random() * SPIN_GLYPHS.length)] ?? "A"
  let guard = 0
  while (glyph === exclude && guard++ < 5) {
    glyph = SPIN_GLYPHS[Math.floor(Math.random() * SPIN_GLYPHS.length)] ?? "A"
  }
  return glyph
}

type UnitOverride = { key: UnitKey; left: string; right: string }

type QueuedOp = { type: "drop" | "add"; key: UnitKey }

function useUnitRows(
  parts: CountdownParts,
  enabled: boolean,
  reducedMotion: boolean
) {
  const [activeKeys, setActiveKeys] = useState<readonly UnitKey[]>(UNIT_KEYS)
  const [override, setOverride] = useState<UnitOverride | null>(null)
  const [queueTick, setQueueTick] = useState(0)
  const [initialized, setInitialized] = useState(false)
  const activeKeysRef = useRef(activeKeys)
  const partsRef = useRef(parts)
  const lastVisibleValuesRef = useRef<CountdownParts>(parts)
  const busyRef = useRef(false)
  const queueRef = useRef<QueuedOp[]>([])
  const timersRef = useRef<ReturnType<typeof setTimeout>[]>([])
  const drainQueueRef = useRef<() => void>(() => {})
  const snapToRef = useRef<(desired: readonly UnitKey[]) => void>(() => {})

  if (enabled && !initialized) {
    setInitialized(true)
    setActiveKeys(activeUnitKeys(parts))
  }

  useEffect(() => {
    partsRef.current = parts
    for (const key of UNIT_KEYS) {
      if (key === "seconds" || parts[key] !== 0) {
        lastVisibleValuesRef.current[key] = parts[key]
      }
    }
  })

  useEffect(() => {
    activeKeysRef.current = activeKeys
  }, [activeKeys])

  const clearTimers = () => {
    for (const t of timersRef.current) clearTimeout(t)
    timersRef.current = []
  }

  const unitCopy = (key: UnitKey) => ({
    left: unitValue(key, {
      ...partsRef.current,
      [key]: lastVisibleValuesRef.current[key],
    }),
    right: (site.units[UNIT_KEYS.indexOf(key)] ?? "").toUpperCase(),
  })

  const replaceChar = (value: string, index: number, char: string) =>
    `${value.slice(0, index)}${char}${value.slice(index + 1)}`

  const setOverrideChar = (
    key: UnitKey,
    side: "left" | "right",
    index: number,
    char: string
  ) => {
    setOverride((current) => {
      if (!current || current.key !== key) return current
      return {
        ...current,
        [side]: replaceChar(current[side], index, char),
      }
    })
  }

  const scheduleFlapScramble = (
    key: UnitKey,
    copy: { left: string; right: string },
    direction: "out" | "in",
    startDelay = 0
  ) => {
    const flaps = (["left", "right"] as const).flatMap((side) =>
      copy[side].split("").map((char, index) => ({ side, index, char }))
    )

    flaps.forEach(({ side, index, char }, flapIndex) => {
      const base = startDelay + flapIndex * UNIT_FLAP_STAGGER_MS
      let previous = direction === "out" ? char : " "

      for (let step = 0; step < SHUFFLE_STEPS; step++) {
        const glyph = randomGlyph(previous)
        previous = glyph
        timersRef.current.push(
          setTimeout(
            () => setOverrideChar(key, side, index, glyph),
            base + step * SOLARI_FLIP_MS.fast
          )
        )
      }

      timersRef.current.push(
        setTimeout(
          () =>
            setOverrideChar(
              key,
              side,
              index,
              direction === "out" ? " " : char
            ),
          base + SHUFFLE_STEPS * SOLARI_FLIP_MS.fast
        )
      )
    })

    const lastFlapDelay = Math.max(0, flaps.length - 1) * UNIT_FLAP_STAGGER_MS
    return (
      startDelay +
      lastFlapDelay +
      (SHUFFLE_STEPS + 1) * SOLARI_FLIP_MS.fast
    )
  }

  const release = (delay: number) => {
    timersRef.current.push(
      setTimeout(() => {
        busyRef.current = false
        setQueueTick((n) => n + 1)
      }, delay)
    )
  }

  const runDrop = (key: UnitKey) => {
    busyRef.current = true
    clearTimers()

    const copy = unitCopy(key)
    setOverride({ key, ...copy })
    const scrambleMs = scheduleFlapScramble(key, copy, "out")

    timersRef.current.push(
      setTimeout(() => {
        setOverride(null)
        setActiveKeys(activeKeysRef.current.filter((k) => k !== key))
      }, scrambleMs)
    )

    release(scrambleMs)
  }

  const runAdd = (key: UnitKey) => {
    busyRef.current = true
    clearTimers()

    const copy = unitCopy(key)
    const withKey = UNIT_KEYS.filter(
      (k) => k === key || activeKeysRef.current.includes(k)
    )

    setOverride({
      key,
      left: " ".repeat(copy.left.length),
      right: " ".repeat(copy.right.length),
    })
    setActiveKeys(withKey)

    const scrambleMs = scheduleFlapScramble(key, copy, "in")

    timersRef.current.push(
      setTimeout(() => setOverride(null), scrambleMs)
    )

    release(scrambleMs)
  }

  const drainQueue = () => {
    if (busyRef.current) return

    while (queueRef.current.length > 0) {
      const op = queueRef.current.shift()!
      const current = activeKeysRef.current
      const desired = activeUnitKeys(partsRef.current)

      if (op.type === "drop") {
        if (!current.includes(op.key) || desired.includes(op.key)) continue
        runDrop(op.key)
        return
      }

      if (current.includes(op.key) || !desired.includes(op.key)) continue
      runAdd(op.key)
      return
    }
  }

  const enqueue = (op: QueuedOp) => {
    queueRef.current = queueRef.current.filter(({ key }) => key !== op.key)
    queueRef.current.push(op)
    queueRef.current.sort(
      (a, b) => UNIT_KEYS.indexOf(a.key) - UNIT_KEYS.indexOf(b.key)
    )
  }

  const snapTo = (desired: readonly UnitKey[]) => {
    clearTimers()
    queueRef.current = []
    busyRef.current = false
    queueMicrotask(() => {
      setOverride(null)
      setActiveKeys(desired)
    })
  }

  useEffect(() => {
    drainQueueRef.current = drainQueue
    snapToRef.current = snapTo
  })

  useEffect(() => {
    if (!enabled || !initialized) return

    const desired = activeUnitKeys(parts)

    if (reducedMotion) {
      snapToRef.current(desired)
      return
    }

    const current = activeKeysRef.current

    for (const key of current) {
      if (!desired.includes(key)) enqueue({ type: "drop", key })
    }
    for (const key of desired) {
      if (!current.includes(key)) enqueue({ type: "add", key })
    }

    drainQueueRef.current()
  }, [parts, enabled, reducedMotion, initialized])

  useEffect(() => {
    if (!enabled || busyRef.current) return
    if (queueRef.current.length === 0) return
    drainQueueRef.current()
  }, [enabled, queueTick])

  useEffect(() => {
    return () => {
      clearTimers()
    }
  }, [])

  return { activeKeys, override }
}

function useBoardReveal(
  target: string[] | null,
  cols: number,
  reducedMotion: boolean
) {
  const [rows, setRows] = useState<string[] | null>(null)
  const [done, setDone] = useState(false)

  useEffect(() => {
    if (!target) return

    const blank = " ".repeat(cols)
    setRows(target.map(() => blank))

    const timers: ReturnType<typeof setTimeout>[] = []

    if (reducedMotion) {
      timers.push(
        setTimeout(() => {
          setRows(target)
          setDone(true)
        }, 0)
      )
      return () => {
        for (const t of timers) clearTimeout(t)
      }
    }

    const setCell = (r: number, c: number, char: string) => {
      setRows((prev) => {
        if (!prev) return prev
        const next = [...prev]
        const chars = next[r]!.split("")
        chars[c] = char
        next[r] = chars.join("")
        return next
      })
    }

    let cellIndex = 0
    let lastLandDelay = 0

    target.forEach((rowStr, r) => {
      rowStr.split("").forEach((finalChar, c) => {
        const index = cellIndex++
        if (finalChar === " ") return

        const base = index * STAGGER_MS
        let prevGlyph = " "

        for (let step = 0; step < SHUFFLE_STEPS; step++) {
          const glyph = randomGlyph(prevGlyph)
          prevGlyph = glyph
          timers.push(
            setTimeout(
              () => setCell(r, c, glyph),
              base + step * SOLARI_FLIP_MS.fast
            )
          )
        }

        const landDelay = base + SHUFFLE_STEPS * SOLARI_FLIP_MS.fast
        lastLandDelay = Math.max(lastLandDelay, landDelay)
        timers.push(setTimeout(() => setCell(r, c, finalChar), landDelay))
      })
    })

    timers.push(
      setTimeout(
        () => setDone(true),
        lastLandDelay + SOLARI_FLIP_MS.fast + 100
      )
    )

    return () => {
      for (const t of timers) clearTimeout(t)
    }
  }, [target, cols, reducedMotion])

  return { rows, done }
}

export function CountdownHero() {
  const hydrated = useHydrated()
  const [parts, setParts] = useState<CountdownParts>(ZERO_PARTS)
  const [revealTarget, setRevealTarget] = useState<string[] | null>(null)
  const [revealRowIds, setRevealRowIds] = useState<string[] | null>(null)

  useEffect(() => {
    if (!hydrated) return

    const startId = window.setTimeout(() => {
      const initialParts = remainingParts(site.targetDate, new Date())
      setParts(initialParts)
      const board = layoutBoard(site, initialParts)
      setRevealTarget(board.rows)
      setRevealRowIds(board.rowIds)
    }, 0)

    const tickId = window.setInterval(() => {
      setParts(remainingParts(site.targetDate, new Date()))
    }, 1000)

    return () => {
      clearTimeout(startId)
      clearInterval(tickId)
    }
  }, [hydrated])

  const reducedMotion = useReducedMotion()

  const { activeKeys, override } = useUnitRows(
    parts,
    revealTarget !== null,
    reducedMotion
  )

  const live = useMemo(() => {
    const unitOverrides = override
      ? { [override.key]: { left: override.left, right: override.right } }
      : undefined
    return layoutBoard(site, parts, BOARD_COLS, {
      unitKeys: activeKeys,
      unitOverrides,
    })
  }, [parts, activeKeys, override])

  const { rows: revealRows, done: revealDone } = useBoardReveal(
    revealTarget,
    live.cols,
    reducedMotion
  )

  const blankRows = useMemo(
    () => live.rows.map(() => " ".repeat(live.cols)),
    [live]
  )

  const boardRows = revealDone ? live.rows : (revealRows ?? blankRows)
  const boardRowIds = revealDone
    ? live.rowIds
    : (revealRowIds ?? live.rowIds)

  const ariaLabel = hydrated
    ? `${site.headline} ${site.name}. ${parts.days} days, ${parts.hours} hours, ${parts.minutes} minutes, ${parts.seconds} seconds until ${site.name}.`
    : `${site.headline} ${site.name}.`

  return (
    <section className="relative h-dvh w-full overflow-hidden bg-background text-foreground">
      <div
        className="hero-reveal h-full w-full"
        style={{ "--hero-delay": "60ms" } as CSSProperties}
      >
        <SolariBoard
          rows={boardRows}
          rowIds={boardRowIds}
          cols={live.cols}
          instant={reducedMotion}
          speed={revealDone ? "normal" : "fast"}
          fill
          ariaLabel={ariaLabel}
        />
      </div>

      <p className="sr-only" aria-live="polite">
        {hydrated
          ? `${parts.days} days, ${parts.hours} hours, ${parts.minutes} minutes, ${parts.seconds} seconds remaining until ${site.name}`
          : `Countdown until ${site.name}`}
      </p>
    </section>
  )
}
```

### `registry/new-york/templates/landing-page-v3/components/solari-board.tsx`

```tsx
"use client"

import {
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
} from "react"

import {
  boardGapPx,
  boardNaturalSize,
  fitBoardFrame,
  fitBoardScale,
  padBoard,
  TARGET_CELL_PX,
} from "../lib/board"
import { SolariCell } from "./solari-cell"

import "../styles/solari.css"

const TILE_SETTLE_MS = 140

type Frame = {
  cols: number
  rows: number
  gap: number
}

type SolariBoardProps = {
  rows: string[]
  rowIds?: string[]
  cols: number
  instant?: boolean
  speed?: "normal" | "fast"
  fill?: boolean
  ariaLabel: string
}

function readRem(): number {
  return (
    Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
  )
}

export function SolariBoard({
  rows,
  rowIds,
  cols,
  instant = false,
  speed = "normal",
  fill = false,
  ariaLabel,
}: SolariBoardProps) {
  const rootRef = useRef<HTMLDivElement>(null)
  const sizerRef = useRef<HTMLDivElement>(null)
  const gridRef = useRef<HTMLDivElement>(null)
  const initialFrame: Frame = { cols, rows: rows.length, gap: 4 }
  const frameRef = useRef<Frame>(initialFrame)
  const [frame, setFrame] = useState<Frame>(initialFrame)
  const [measured, setMeasured] = useState(!fill)
  const [snap, setSnap] = useState(false)

  const ids =
    rowIds && rowIds.length === rows.length
      ? rowIds
      : rows.map((_, i) => `row-${i}`)

  useLayoutEffect(() => {
    if (!fill) return
    const el = rootRef.current
    if (!el) return

    const paintScale = (
      tileCols: number,
      tileRows: number,
      gap: number,
      width: number,
      height: number
    ) => {
      const natural = boardNaturalSize(tileCols, tileRows, gap)
      const scale = fitBoardScale(width, height, tileCols, tileRows, gap)
      const sizer = sizerRef.current
      const grid = gridRef.current
      if (sizer) {
        sizer.style.width = `${natural.width * scale}px`
        sizer.style.height = `${natural.height * scale}px`
      }
      if (grid) {
        grid.style.width = `${natural.width}px`
        grid.style.gap = `${gap}px`
        grid.style.transform = `scale(${scale})`
      }
    }

    const applyTiles = () => {
      const gap = boardGapPx(el.clientWidth, readRem())
      const tiles = fitBoardFrame(
        el.clientWidth,
        el.clientHeight,
        cols,
        rows.length,
        gap
      )
      const next = { cols: tiles.cols, rows: tiles.rows, gap }
      const changed =
        next.cols !== frameRef.current.cols ||
        next.rows !== frameRef.current.rows ||
        next.gap !== frameRef.current.gap
      paintScale(next.cols, next.rows, next.gap, el.clientWidth, el.clientHeight)
      setMeasured(true)
      if (!changed) return
      frameRef.current = next
      setSnap(true)
      setFrame(next)
    }

    applyTiles()

    let tileTimer: ReturnType<typeof setTimeout> | undefined
    const observer = new ResizeObserver(() => {
      const gap = boardGapPx(el.clientWidth, readRem())
      paintScale(
        frameRef.current.cols,
        frameRef.current.rows,
        gap,
        el.clientWidth,
        el.clientHeight
      )
      if (tileTimer) clearTimeout(tileTimer)
      tileTimer = setTimeout(applyTiles, TILE_SETTLE_MS)
    })
    observer.observe(el)
    return () => {
      observer.disconnect()
      if (tileTimer) clearTimeout(tileTimer)
    }
  }, [fill, cols, rows.length])

  useEffect(() => {
    if (!snap) return
    const id = requestAnimationFrame(() => setSnap(false))
    return () => cancelAnimationFrame(id)
  }, [snap])

  const layout = fill
    ? padBoard({ rows, cols, rowIds: ids }, frame.cols, frame.rows)
    : { rows, cols, rowIds: ids }

  const style: CSSProperties | undefined = fill
    ? {
        opacity: measured ? 1 : 0,
      }
    : undefined

  const grid = (
    <div
      ref={fill ? gridRef : undefined}
      className="solari-grid"
      style={
        {
          gridTemplateColumns: fill
            ? `repeat(${layout.cols}, ${TARGET_CELL_PX}px)`
            : `repeat(${layout.cols}, minmax(0, 1fr))`,
          gap: fill ? frame.gap : undefined,
          ...(fill ? { "--solari-cell-size": `${TARGET_CELL_PX}px` } : {}),
        } as CSSProperties
      }
    >
      {layout.rows.map((row, r) => {
        const id = layout.rowIds[r] ?? `row-${r}`
        return (
          <div
            key={id}
            className="solari-row"
            style={{
              gridColumn: "1 / -1",
              display: "grid",
              gridTemplateColumns: "subgrid",
            }}
          >
            {row
              .padEnd(layout.cols, " ")
              .slice(0, layout.cols)
              .split("")
              .map((ch, c) => (
                <SolariCell
                  key={`${id}-${c}`}
                  value={ch}
                  instant={instant || snap}
                  speed={speed}
                />
              ))}
          </div>
        )
      })}
    </div>
  )

  return (
    <div
      ref={rootRef}
      className={fill ? "solari-board solari-board-fill" : "solari-board"}
      style={style}
      role="img"
      aria-label={ariaLabel}
    >
      <svg className="solari-defs" aria-hidden>
        <filter
          id="solari-paper-grain"
          x="0%"
          y="0%"
          width="100%"
          height="100%"
        >
          <feTurbulence
            type="fractalNoise"
            baseFrequency="0.85"
            numOctaves="4"
            stitchTiles="stitch"
          />
        </filter>
      </svg>
      {fill ? (
        <div ref={sizerRef} className="solari-sizer">
          {grid}
        </div>
      ) : (
        grid
      )}
    </div>
  )
}
```

### `registry/new-york/templates/landing-page-v3/components/solari-cell.tsx`

```tsx
"use client"

import { useEffect, useRef, useState, type CSSProperties } from "react"

export const SOLARI_FLIP_MS = { normal: 260, fast: 130 } as const

type SolariCellProps = {
  value: string
  instant?: boolean
  speed?: "normal" | "fast"
}

export function SolariEmpty() {
  const nbsp = String.fromCharCode(160)
  return (
    <div className="solari-cell solari-cell-empty" aria-hidden>
      <div className="solari-half solari-half-top solari-static-top">
        <span className="solari-glyph">{nbsp}</span>
      </div>
      <div className="solari-half solari-half-bottom solari-static-bottom">
        <span className="solari-glyph">{nbsp}</span>
      </div>
    </div>
  )
}

export function SolariCell({
  value,
  instant = false,
  speed = "normal",
}: SolariCellProps) {
  const empty = value === " " || value === ""
  const display = empty ? " " : value
  const [current, setCurrent] = useState(display)
  const [previous, setPrevious] = useState(display)
  const [flipping, setFlipping] = useState(false)
  const [flipSeq, setFlipSeq] = useState(0)
  const currentRef = useRef(display)
  const flipTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
  const flipMs = SOLARI_FLIP_MS[speed]

  useEffect(() => {
    if (display === currentRef.current) return

    const startId = window.setTimeout(() => {
      if (instant) {
        currentRef.current = display
        setPrevious(display)
        setCurrent(display)
        setFlipping(false)
        return
      }

      const from = currentRef.current
      currentRef.current = display
      setPrevious(from)
      setCurrent(display)
      setFlipping(true)
      setFlipSeq((n) => n + 1)

      if (flipTimer.current) clearTimeout(flipTimer.current)
      flipTimer.current = setTimeout(() => {
        setFlipping(false)
        setPrevious(display)
      }, flipMs)
    }, 0)

    return () => {
      clearTimeout(startId)
      if (flipTimer.current) clearTimeout(flipTimer.current)
    }
  }, [display, instant, flipMs])

  const showFlip = flipping && previous !== current
  const style =
    speed === "fast"
      ? ({ "--solari-duration": `${flipMs}ms` } as CSSProperties)
      : undefined

  return (
    <div
      className={`solari-cell${empty ? " solari-cell-empty" : ""}`}
      style={style}
      aria-hidden
    >
      <div className="solari-half solari-half-top solari-static-top">
        <span className="solari-glyph">{current}</span>
      </div>
      <div className="solari-half solari-half-bottom solari-static-bottom">
        <span className="solari-glyph">{showFlip ? previous : current}</span>
      </div>

      {showFlip ? (
        <>
          <div
            key={`top-${flipSeq}`}
            className="solari-half solari-half-top solari-flip-top"
          >
            <span className="solari-glyph">{previous}</span>
          </div>
          <div
            key={`bottom-${flipSeq}`}
            className="solari-half solari-half-bottom solari-flip-bottom"
          >
            <span className="solari-glyph">{current}</span>
          </div>
        </>
      ) : null}
    </div>
  )
}
```

### `registry/new-york/templates/landing-page-v3/lib/board.ts`

```ts
import { padDigits, type CountdownParts } from "./countdown"

export const BOARD_COLS = 23

const MAX_HEADING_LINES = 3
const MAX_NAME_LINES = 2

export const UNIT_KEYS = ["days", "hours", "minutes", "seconds"] as const
export type UnitKey = (typeof UNIT_KEYS)[number]

const UNIT_PADDED: Record<UnitKey, boolean> = {
  days: false,
  hours: true,
  minutes: true,
  seconds: true,
}

export function activeUnitKeys(parts: CountdownParts): UnitKey[] {
  return UNIT_KEYS.filter((key) => key === "seconds" || parts[key] !== 0)
}

export type BoardSite = {
  headline: string
  name: string
  units: readonly string[]
}

export type BoardLayout = {
  rows: string[]
  cols: number
  rowIds: string[]
}

export const UNIT_SLOT_COUNT = UNIT_KEYS.length

export const TARGET_CELL_PX = 40
export const CELL_ASPECT = 4 / 3
const MAX_FRAME_COLS = 45
const MAX_FRAME_ROWS = 28

export function boardGapPx(viewportWidth: number, rem = 16): number {
  return Math.min(0.3 * rem, Math.max(0.16 * rem, 0.005 * viewportWidth))
}

export function boardNaturalSize(
  cols: number,
  rows: number,
  gap: number
): { width: number; height: number } {
  return {
    width: cols * TARGET_CELL_PX + Math.max(0, cols - 1) * gap,
    height: rows * TARGET_CELL_PX * CELL_ASPECT + Math.max(0, rows - 1) * gap,
  }
}

export function fitBoardScale(
  frameWidth: number,
  frameHeight: number,
  cols: number,
  rows: number,
  gap: number
): number {
  const natural = boardNaturalSize(cols, rows, gap)
  return Math.min(
    frameWidth / Math.max(1, natural.width),
    frameHeight / Math.max(1, natural.height),
    1
  )
}

export function fitBoardFrame(
  width: number,
  height: number,
  minCols: number,
  minRows: number,
  gap: number
): { cols: number; rows: number } {
  if (width <= 0 || height <= 0) return { cols: minCols, rows: minRows }

  const preferredCols = Math.floor((width + gap) / (TARGET_CELL_PX + gap))
  if (preferredCols < minCols) {
    return { cols: minCols, rows: minRows }
  }

  const cols = Math.min(MAX_FRAME_COLS, preferredCols)
  const cellH = TARGET_CELL_PX * CELL_ASPECT
  const rows = Math.min(
    MAX_FRAME_ROWS,
    Math.max(minRows, Math.floor((height + gap) / (cellH + gap)))
  )

  return { cols, rows }
}

export function padBoard(
  layout: BoardLayout,
  targetCols: number,
  targetRows: number
): BoardLayout {
  const cols = Math.max(layout.cols, targetCols)
  const rowCount = Math.max(layout.rows.length, targetRows)
  const extraX = cols - layout.cols
  const left = Math.floor(extraX / 2)
  const extraY = rowCount - layout.rows.length
  const top = Math.floor(extraY / 2)
  const blank = " ".repeat(cols)
  const padded = layout.rows.map((row) => {
    const inner = row.padEnd(layout.cols, " ").slice(0, layout.cols)
    return `${" ".repeat(left)}${inner}${" ".repeat(cols - left - layout.cols)}`
  })
  const topIds = Array.from({ length: top }, (_, i) => `pad-top-${i}`)
  const bottomIds = Array.from(
    { length: rowCount - top - padded.length },
    (_, i) => `pad-bottom-${i}`
  )
  return {
    cols,
    rows: [
      ...Array.from({ length: top }, () => blank),
      ...padded,
      ...Array.from({ length: rowCount - top - padded.length }, () => blank),
    ],
    rowIds: [...topIds, ...layout.rowIds, ...bottomIds],
  }
}

export function centerIn(text: string, width: number): string {
  const trimmed = text.slice(0, width)
  const pad = width - trimmed.length
  const left = Math.floor(pad / 2)
  return `${" ".repeat(left)}${trimmed}${" ".repeat(pad - left)}`
}

export function wrapToRows(text: string, cols: number, maxLines = Infinity): string[] {
  const words = text.toUpperCase().split(/\s+/).filter(Boolean)
  const lines: string[] = []
  let line = ""

  for (const word of words) {
    const next = line ? `${line} ${word}` : word
    if (next.length > cols) {
      if (line) lines.push(line)
      line = word.length > cols ? word.slice(0, cols) : word
    } else {
      line = next
    }
  }
  if (line) lines.push(line)
  if (lines.length === 0) return [""]

  if (lines.length <= maxLines) return lines

  const kept = lines.slice(0, maxLines)
  const last = kept[maxLines - 1]!
  kept[maxLines - 1] =
    last.length >= cols ? `${last.slice(0, cols - 1)}…` : `${last}…`
  return kept
}

export function wrapBalanced(text: string, width: number, maxLines = Infinity): string[] {
  const greedy = wrapToRows(text, width, maxLines)
  if (greedy.length <= 1) return greedy

  const words = text.toUpperCase().split(/\s+/).filter(Boolean)
  const minWidth = Math.max(
    1,
    ...words.map((word) => Math.min(word.length, width))
  )
  let lo = minWidth
  let hi = width
  while (lo < hi) {
    const mid = Math.floor((lo + hi) / 2)
    if (wrapToRows(text, mid, Infinity).length <= greedy.length) hi = mid
    else lo = mid + 1
  }
  return wrapToRows(text, lo, maxLines)
}

export function firstGapIndex(row: string): number {
  const start = row.search(/\S/)
  if (start < 0) return Math.floor(row.length / 2)
  const gap = row.indexOf(" ", start)
  return gap >= 0 ? gap : Math.floor(row.length / 2)
}

function splitPair(text: string): { left: string; right: string } {
  const words = text.toUpperCase().split(/\s+/).filter(Boolean)
  if (words.length === 0) return { left: "", right: "" }
  if (words.length === 1) return { left: words[0]!, right: "" }
  return { left: words[0]!, right: words.slice(1).join(" ") }
}

export function placeAtAxis(
  left: string,
  right: string,
  cols: number,
  axis: number
): string {
  const cells = Array.from({ length: cols }, () => " ")
  const leftStart = axis - left.length
  for (let i = 0; i < left.length; i++) {
    const col = leftStart + i
    if (col >= 0 && col < axis) cells[col] = left[i]!
  }
  for (let i = 0; i < right.length; i++) {
    const col = axis + 1 + i
    if (col >= 0 && col < cols) cells[col] = right[i]!
  }
  return cells.join("")
}

export function unitValue(key: UnitKey, parts: CountdownParts): string {
  return UNIT_PADDED[key]
    ? padDigits(parts[key], 2)
    : String(Math.max(0, Math.floor(parts[key])))
}

export type UnitOverride = { left?: string; right?: string }

export type LayoutBoardOptions = {
  unitKeys?: readonly UnitKey[]
  unitOverrides?: Partial<Record<UnitKey, UnitOverride>>
}

export function layoutBoard(
  site: BoardSite,
  parts: CountdownParts,
  cols: number = BOARD_COLS,
  options?: LayoutBoardOptions
): BoardLayout {
  const width = cols
  const blank = " ".repeat(width)

  const headlinePairs = wrapBalanced(site.headline, width, MAX_HEADING_LINES).map(
    splitPair
  )
  const namePair = splitPair(site.name)
  const keys = options?.unitKeys ?? activeUnitKeys(parts)
  const unitPairs = keys.map((key) => {
    const override = options?.unitOverrides?.[key]
    const i = UNIT_KEYS.indexOf(key)
    return {
      key,
      left: override?.left ?? unitValue(key, parts),
      right: override?.right ?? (site.units[i] ?? "").toUpperCase(),
    }
  })

  const padCount = Math.max(0, UNIT_SLOT_COUNT - unitPairs.length)
  const slotPairs = [
    ...unitPairs,
    ...Array.from({ length: padCount }, () => ({
      key: null as UnitKey | null,
      left: "",
      right: "",
    })),
  ]

  const pairs = [
    ...headlinePairs,
    namePair,
    ...slotPairs.map(({ left, right }) => ({ left, right })),
  ]
  const maxLeft = Math.max(1, ...pairs.map((pair) => pair.left.length))
  const maxRight = Math.max(0, ...pairs.map((pair) => pair.right.length))
  const contentWidth = Math.min(width, maxLeft + 1 + maxRight)
  const leftPad = Math.max(0, Math.floor((width - contentWidth) / 2) - 1)
  const axis = leftPad + maxLeft

  const nameRightWidth = Math.max(1, width - axis - 1)
  const nameRows = wrapToRows(namePair.right, nameRightWidth, MAX_NAME_LINES).map(
    (line, i) => placeAtAxis(i === 0 ? namePair.left : "", line, width, axis)
  )

  const unitRows = slotPairs.map((pair) =>
    pair.key === null
      ? blank
      : placeAtAxis(pair.left, pair.right, width, axis)
  )

  const unitRowIds = slotPairs.map((_, i) => `unit-slot-${i}`)

  const rows = [
    blank,
    ...headlinePairs.map((pair) => placeAtAxis(pair.left, pair.right, width, axis)),
    blank,
    ...nameRows,
    blank,
    ...unitRows,
    blank,
  ]

  const rowIds = [
    "spacer-top",
    ...headlinePairs.map((_, i) => `headline-${i}`),
    "spacer-after-headline",
    ...nameRows.map((_, i) => `name-${i}`),
    "spacer-before-units",
    ...unitRowIds,
    "spacer-bottom",
  ]

  return { rows, cols: width, rowIds }
}

export function buildBoardRows(
  site: BoardSite,
  parts: CountdownParts,
  cols?: number
): string[] {
  return layoutBoard(site, parts, cols).rows
}
```

### `registry/new-york/templates/landing-page-v3/lib/countdown.ts`

```ts
export type CountdownParts = {
  days: number
  hours: number
  minutes: number
  seconds: number
}

const MS_PER_SECOND = 1000
const MS_PER_MINUTE = 60 * MS_PER_SECOND
const MS_PER_HOUR = 60 * MS_PER_MINUTE
const MS_PER_DAY = 24 * MS_PER_HOUR

export function remainingParts(target: Date | string, now: Date): CountdownParts {
  const end = typeof target === "string" ? new Date(target) : target
  const diff = Math.max(0, end.getTime() - now.getTime())

  const days = Math.floor(diff / MS_PER_DAY)
  const hours = Math.floor((diff % MS_PER_DAY) / MS_PER_HOUR)
  const minutes = Math.floor((diff % MS_PER_HOUR) / MS_PER_MINUTE)
  const seconds = Math.floor((diff % MS_PER_MINUTE) / MS_PER_SECOND)

  return { days, hours, minutes, seconds }
}

export function padDigits(value: number, width = 2): string {
  return String(Math.max(0, Math.floor(value))).padStart(width, "0")
}
```

### `registry/new-york/templates/landing-page-v3/lib/site.ts`

```ts
export const site = {
  headline: "Countdown Until",
  name: "uiception 2.0",
  targetDate: "2026-12-18T00:00:00-05:00",
  units: ["Days", "Hours", "Minutes", "Seconds"] as const,
} as const
```

### `registry/new-york/templates/landing-page-v3/styles/solari.css`

```css
.solari-board {
  --solari-ink: 6%;
  --solari-empty-ink: 1.5%;
  --solari-duration: 260ms;
  --solari-paper: var(--secondary);
  --solari-text: var(--foreground);

  position: relative;
  width: 100%;
  max-width: 64rem;
  margin-inline: auto;
}

.solari-board-fill {
  max-width: none;
  width: 100%;
  height: 100%;
  margin: 0;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
}

.solari-sizer {
  position: relative;
  flex: 0 0 auto;
}

.solari-sizer .solari-grid {
  position: absolute;
  top: 0;
  left: 0;
  transform-origin: top left;
}

.dark .solari-board {
  --solari-ink: 10%;
  --solari-empty-ink: 2%;
  --solari-paper: var(--card);
  --solari-text: var(--foreground);
}

.solari-defs {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

.solari-grid {
  position: relative;
  display: grid;
  gap: clamp(0.16rem, 0.5vw, 0.3rem);
  perspective: 900px;
}

.solari-cell {
  --cell-ink: var(--solari-ink);
  --flap-top-ink: calc(var(--cell-ink) - 3%);
  --flap-bottom-ink: calc(var(--cell-ink) + 6%);
  --paper-mix: color-mix(
    in oklch,
    var(--foreground) var(--cell-ink),
    var(--background)
  );
  --solari-well: color-mix(in oklch, var(--solari-paper) 58%, var(--paper-mix));
  --solari-flap-top: color-mix(
    in oklch,
    var(--solari-paper) 70%,
    color-mix(in oklch, var(--foreground) var(--flap-top-ink), var(--background))
  );
  --solari-flap-bottom: color-mix(
    in oklch,
    var(--solari-paper) 48%,
    color-mix(in oklch, var(--foreground) var(--flap-bottom-ink), var(--background))
  );
  --solari-hinge: color-mix(in oklch, var(--foreground) 30%, var(--solari-paper));
  --solari-tab: color-mix(in oklch, var(--foreground) 28%, var(--solari-paper));

  position: relative;
  aspect-ratio: 3 / 4;
  border-radius: 0.16rem;
  background: var(--solari-well);
  box-shadow:
    0.5px 0 1.5px color-mix(in oklch, var(--foreground) 18%, transparent) inset,
    -0.5px 0 1.5px color-mix(in oklch, var(--foreground) 12%, transparent) inset,
    0 1px 1px color-mix(in oklch, var(--foreground) 14%, transparent) inset,
    0 0 0 1px color-mix(in oklch, var(--foreground) 7%, transparent) inset;
  overflow: hidden;
  font-family: var(--font-mono, ui-monospace, monospace);
  font-weight: 600;
  font-size: clamp(0.7rem, 2.8vw, 1.55rem);
  line-height: 1;
  letter-spacing: 0.01em;
  color: var(--solari-text);
  text-transform: uppercase;
  user-select: none;
}

.solari-board-fill .solari-cell {
  font-size: calc(var(--solari-cell-size, 2.5rem) * 0.52);
}

.solari-cell:nth-child(3n):not(.solari-cell-empty) {
  --cell-ink: calc(var(--solari-ink) + 1.4%);
}

.solari-cell:nth-child(5n):not(.solari-cell-empty) {
  --cell-ink: calc(var(--solari-ink) - 1.1%);
}

.solari-cell:nth-child(7n):not(.solari-cell-empty) {
  --flap-top-ink: calc(var(--cell-ink) - 4.5%);
}

.solari-cell-empty {
  --cell-ink: var(--solari-empty-ink);
  --flap-top-ink: var(--cell-ink);
  --flap-bottom-ink: var(--cell-ink);
  --solari-hinge: color-mix(in oklch, var(--foreground) 6%, var(--background));
  --solari-tab: transparent;
  opacity: 0.4;
  box-shadow: none;
}

.dark .solari-cell-empty {
  opacity: 0.22;
}

.solari-cell::before,
.solari-cell::after {
  content: "";
  position: absolute;
  left: 50%;
  z-index: 5;
  width: 28%;
  height: 8%;
  transform: translateX(-50%);
  background: var(--solari-tab);
  border-radius: 0.05rem;
  opacity: 0.5;
  pointer-events: none;
}

.solari-cell::before {
  top: 3%;
}

.solari-cell::after {
  bottom: 3%;
}

.solari-cell-empty::before,
.solari-cell-empty::after {
  opacity: 0;
}

.solari-half {
  position: absolute;
  left: 0;
  width: 100%;
  height: 50%;
  overflow: hidden;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}

.solari-half::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  background: #737373;
  opacity: 0.08;
  mix-blend-mode: overlay;
  filter: url(#solari-paper-grain);
  pointer-events: none;
}

.dark .solari-half::before {
  opacity: 0.1;
}

.solari-cell-empty .solari-half::before {
  opacity: 0.03;
}

.solari-half-top {
  top: 0;
  transform-origin: bottom center;
  border-radius: 0.14rem 0.14rem 0 0;
  background-color: var(--solari-flap-top);
  background-image:
    repeating-linear-gradient(
      90deg,
      color-mix(in oklch, var(--foreground) 4%, transparent) 0 1px,
      transparent 1px 4px
    ),
    linear-gradient(
      180deg,
      color-mix(in oklch, var(--solari-paper) 55%, var(--solari-flap-top)) 0%,
      var(--solari-flap-top) 42%,
      color-mix(in oklch, var(--solari-flap-top) 78%, var(--solari-hinge)) 100%
    );
  box-shadow:
    0 1.5px 0 color-mix(in oklch, var(--solari-paper) 75%, oklch(1 0 0)) inset,
    0 -1px 0 color-mix(in oklch, var(--solari-paper) 55%, oklch(1 0 0)),
    0 1px 0 var(--solari-hinge);
  z-index: 2;
}

.solari-half-bottom {
  bottom: 0;
  transform-origin: top center;
  border-radius: 0 0 0.14rem 0.14rem;
  background-color: var(--solari-flap-bottom);
  background-image:
    repeating-linear-gradient(
      90deg,
      color-mix(in oklch, var(--foreground) 4%, transparent) 0 1px,
      transparent 1px 4px
    ),
    linear-gradient(
      180deg,
      color-mix(in oklch, var(--solari-flap-bottom) 72%, var(--solari-hinge)) 0%,
      var(--solari-flap-bottom) 28%,
      color-mix(in oklch, var(--solari-flap-bottom) 88%, var(--solari-paper)) 100%
    );
  box-shadow:
    0 3px 4px color-mix(in oklch, var(--foreground) 28%, transparent) inset,
    0 3px 4px color-mix(in oklch, var(--foreground) 18%, transparent);
  z-index: 1;
}

.solari-glyph {
  position: absolute;
  left: 0;
  z-index: 1;
  width: 100%;
  height: 200%;
  display: flex;
  align-items: center;
  justify-content: center;
  text-shadow:
    0 0.6px 0 color-mix(in oklch, var(--foreground) 22%, transparent),
    0 0 0.4px color-mix(in oklch, var(--foreground) 35%, transparent);
}

.solari-half-top .solari-glyph {
  top: 0;
}

.solari-half-bottom .solari-glyph {
  bottom: 0;
}

.solari-static-top {
  z-index: 0;
}

.solari-static-bottom {
  z-index: 0;
}

.solari-flip-top {
  z-index: 3;
  animation: solari-flip-top var(--solari-duration) ease-in both;
}

.solari-flip-bottom {
  z-index: 3;
  animation: solari-flip-bottom var(--solari-duration) ease-out both;
}

@keyframes solari-flip-top {
  from {
    transform: rotateX(0deg);
  }
  to {
    transform: rotateX(-90deg);
  }
}

@keyframes solari-flip-bottom {
  from {
    transform: rotateX(90deg);
  }
  to {
    transform: rotateX(0deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  .solari-board {
    --solari-duration: 0ms;
  }

  .solari-flip-top,
  .solari-flip-bottom {
    animation: none;
  }
}
```

### `registry/new-york/templates/landing-page-v3/.env.example`

```example
# Landing Page v3 needs no environment variables to run. Countdown target,
# copy, and the solari-board content all live in the template's lib/ files.
# Wire up your own form/CRM backend where the template marks the stub.
```
