Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
transform: translateX(-50%);
}
}
@keyframes wave-run {
to {
transform: translateX(-11px);
}
}
@keyframes marquee-rev {
from {
transform: translateX(-50%);
Expand Down Expand Up @@ -197,6 +202,23 @@ button {
transform: translateY(0);
transition-duration: 0.15s;
}
/* the sound toggle: a flat line when off, a travelling sine wave when on */
.wave-amp {
transform-box: fill-box;
transform-origin: center;
transform: scaleY(0.04);
transition: transform 0.7s var(--ease-out-expo);
}
.sound-on .wave-amp {
transform: scaleY(1);
}
.wave-run {
animation: wave-run 1.1s linear infinite;
animation-play-state: paused;
}
.sound-on .wave-run {
animation-play-state: running;
}
.gutter {
padding-left: max(var(--gutter), env(safe-area-inset-left));
padding-right: max(var(--gutter), env(safe-area-inset-right));
Expand Down
62 changes: 20 additions & 42 deletions components/home/Nav.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"
import Image from "next/image"
import { AnimatePresence, motion } from "motion/react"
import { Magnetic } from "./ui"
import SoundToggle from "./SoundToggle"

const LINKS = [
["#vision", "Vision"],
Expand All @@ -21,8 +22,9 @@ function goTo(href) {
else el.scrollIntoView({ behavior: "smooth" })
}

/* The pill on phones: its label rolls from Menu to Close, and its two lines cross into an X. */
/* On phones: two thin lines that cross into an X. No words, no frame. */
function MenuButton({ open, onClick, buttonRef }) {
const line = "absolute left-1/2 h-[1.5px] w-[22px] -translate-x-1/2 bg-current transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]"
return (
<button
ref={buttonRef}
Expand All @@ -31,37 +33,10 @@ function MenuButton({ open, onClick, buttonRef }) {
aria-expanded={open}
aria-controls="mobile-menu"
aria-label={open ? "Close menu" : "Open menu"}
className="flex h-11 items-center gap-3 rounded-full border border-white/50 pl-4 pr-3.5 font-mono text-[0.68rem] uppercase leading-none tracking-[0.2em] sm:hidden"
className="relative h-11 w-11 sm:hidden"
>
<span className="relative block overflow-hidden" aria-hidden="true">
<span className="invisible block">Close</span>
<span
className={`absolute inset-0 block transition-transform duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] ${
open ? "-translate-y-full" : "translate-y-0"
}`}
>
Menu
</span>
<span
className={`absolute inset-0 block transition-transform duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] ${
open ? "translate-y-0" : "translate-y-full"
}`}
>
Close
</span>
</span>
<span className="relative block h-3 w-4" aria-hidden="true">
<span
className={`absolute left-0 h-px w-full bg-current transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] ${
open ? "top-1.5 rotate-45" : "top-0.5"
}`}
/>
<span
className={`absolute left-0 h-px w-full bg-current transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] ${
open ? "top-1.5 -rotate-45" : "top-2.5"
}`}
/>
</span>
<span className={`${line} ${open ? "top-1/2 rotate-45" : "top-[calc(50%-4px)]"}`} aria-hidden="true" />
<span className={`${line} ${open ? "top-1/2 -rotate-45" : "top-[calc(50%+3px)]"}`} aria-hidden="true" />
</button>
)
}
Expand Down Expand Up @@ -178,17 +153,20 @@ export default function Nav() {
<span className="hidden font-mono text-[0.7rem] uppercase tracking-[0.32em] sm:inline">Existence</span>
</a>

<nav className="hidden items-center gap-6 sm:flex" aria-label="Sections">
{LINKS.map(([href, label]) => (
<Magnetic key={href} strength={0.25}>
<a href={href} className="label block px-2 py-3 !opacity-90 hover:!opacity-100">
{label}
</a>
</Magnetic>
))}
</nav>

<MenuButton open={open} onClick={() => setOpen((v) => !v)} buttonRef={buttonRef} />
<div className="flex items-center gap-2 sm:gap-6">
<nav className="hidden items-center gap-6 sm:flex" aria-label="Sections">
{LINKS.map(([href, label]) => (
<Magnetic key={href} strength={0.25}>
<a href={href} className="label block px-2 py-3 !opacity-90 hover:!opacity-100">
{label}
</a>
</Magnetic>
))}
</nav>
<span className="hidden h-4 w-px bg-white/30 sm:block" aria-hidden="true" />
<SoundToggle />
<MenuButton open={open} onClick={() => setOpen((v) => !v)} buttonRef={buttonRef} />
</div>
</div>
</header>

Expand Down
131 changes: 131 additions & 0 deletions components/home/SoundToggle.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"use client"

import { useEffect, useRef, useState } from "react"
import { Ambient } from "@lib/ambient"

const KEY = "existence-sound"

const readPref = () => {
try {
return localStorage.getItem(KEY) === "on"
} catch {
return false
}
}
const writePref = (on) => {
try {
localStorage.setItem(KEY, on ? "on" : "off")
} catch {}
}

/*
* The sound switch in the navbar. Browsers only allow sound after the visitor interacts,
* so the score starts off; turning it on is remembered, and on a return visit it resumes
* at the first tap or key press. It fades out whenever the tab is hidden.
*/
export default function SoundToggle() {
const [on, setOn] = useState(false)
const engine = useRef(null)
const wanted = useRef(false)

const getEngine = () => {
if (!engine.current) {
engine.current = new Ambient()
// development only: lets the engine be measured from the console
if (process.env.NODE_ENV !== "production") window.__ambient = engine.current
}
return engine.current
}

const play = () => {
// on iPhones this lets the score play even with the ringer switch on silent
try {
if (navigator.audioSession) navigator.audioSession.type = "playback"
} catch {}
const e = getEngine()
e.start()
e.setProgress(progress())
setOn(true)
}

const pause = () => {
engine.current?.stop()
setOn(false)
}

const toggle = () => {
wanted.current = !on
writePref(wanted.current)
wanted.current ? play() : pause()
}

// returning visitors who left the sound on: resume at their first interaction
useEffect(() => {
if (!readPref()) return
wanted.current = true
const resume = (e) => {
// a tap on the toggle itself is handled by the toggle
if (!e.target?.closest?.("[data-sound-toggle]") && wanted.current) play()
cleanup()
}
const cleanup = () => {
window.removeEventListener("pointerdown", resume, true)
window.removeEventListener("keydown", resume, true)
}
window.addEventListener("pointerdown", resume, true)
window.addEventListener("keydown", resume, true)
return cleanup
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

// the score follows the page: harmony and brightness shift as you scroll
useEffect(() => {
const onScroll = () => engine.current?.setProgress(progress())
window.addEventListener("scroll", onScroll, { passive: true })
return () => window.removeEventListener("scroll", onScroll)
}, [])

// fade out when the tab is hidden, back in when it returns
useEffect(() => {
const onVisibility = () => {
if (!engine.current || !wanted.current) return
if (document.hidden) engine.current.stop()
else engine.current.start()
}
document.addEventListener("visibilitychange", onVisibility)
return () => document.removeEventListener("visibilitychange", onVisibility)
}, [])

useEffect(() => () => engine.current?.stop(), [])

return (
<button
type="button"
data-sound-toggle
data-cursor={on ? "mute" : "sound"}
onClick={toggle}
aria-pressed={on}
aria-label={on ? "Turn sound off" : "Turn sound on"}
className={`relative flex h-11 w-11 items-center justify-center ${on ? "sound-on" : ""}`}
>
{/* off: a flat line, silence. on: the same line swells into a travelling sine wave */}
<svg width="22" height="12" viewBox="0 0 22 12" className="overflow-hidden" aria-hidden="true">
<g className="wave-amp">
<path className="wave-run" d={WAVE} fill="none" stroke="currentColor" strokeWidth="1.5" vectorEffect="non-scaling-stroke" strokeLinecap="round" />
</g>
</svg>
</button>
)
}

// two and a bit periods of a sine (period 11), so it can slide one period and loop seamlessly
const WAVE = (() => {
let d = ""
for (let x = 0; x <= 33; x += 0.5) d += `${x === 0 ? "M" : "L"}${x} ${(6 - Math.sin((x / 11) * Math.PI * 2) * 4).toFixed(2)} `
return d.trim()
})()

function progress() {
const max = document.documentElement.scrollHeight - window.innerHeight
return max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0
}
Loading
Loading