Layr Components

Components / Navigation

Hyphen Nav

A navigation that is the hyphen in a two-word name: it closes to a chip as you scroll, opens on hover, and becomes the phone menu.

Scroll insideFull screen
Props
700 px
#F5F4F1
#0E0E0D
#0E0E0D
#FFFFFF
#E6E5E1
32 px
17 px
15 px
38 px
6 px
3 px
9 px
56 px

Set in code: Name link, Links, Font file. The preview fills these in with demo content.

Scroll, then point at the chip at the top to open it.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Hyphen Nav shows up in the Insert menu, and its controls appear in the right panel.
// Hyphen Nav · Layr Components
//
// The navigation is the hyphen in a two-word name. At the top of a page the hyphen
// is open from one word to the other and the links sit inside it. As the page is
// scrolled it closes, following the scroll, to a small "North - Studio" chip in the
// middle of the screen. Point at it, tab into it or scroll up and it opens in place,
// away from the word under the pointer; the link you point at widens. Where the links
// do not fit (phones), the hyphen is the menu button and opens to the whole screen.
// Paste into a new code file in Framer. Place it in a fixed (or sticky) frame at the
// top of the page, as wide as the window and 60 to 72px tall. The name and links
// widen and narrow on a variable font's width axis: without Font family or Font file
// it loads Mona Sans from Google Fonts. Without a width axis it is plain type.

import * as React from "react"
import { addPropertyControls, ControlType, Link, useIsStaticRenderer } from "framer"

// The words widen and narrow on the font's width axis ("wdth"). Without a font of your own, Mona Sans loads
// from Google Fonts: free (SIL Open Font License), with a width axis from 75 to 125.
const GOOGLE_FONT = "https://fonts.googleapis.com/css2?family=Mona+Sans:wdth,wght@75..125,200..900&display=swap"
const hashOf = (s: string) => {
    let h = 5381
    for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
    return (h >>> 0).toString(36)
}
function widthFont(fontFamily: string, fontFile?: string) {
    const family = String(fontFamily || "").trim()
    if (family) return { name: family, css: "" }
    if (fontFile) {
        const name = `Lyr Width ${hashOf(fontFile)}`
        return { name, css: `@font-face{font-family:"${name}";src:url("${fontFile}") format("woff2");font-weight:100 900;font-stretch:25% 200%;font-display:swap}` }
    }
    return { name: "Mona Sans", css: `@import url("${GOOGLE_FONT}");` }
}

const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
const NARROW = 62, PEAK = 125
// the name is wide when the hyphen is closed and gives way as it opens; links rest at their normal width
const NAME_CLOSED = 116, NAME_OPEN = 88, REST = 100
// width of a word (per px of font size) at a given width setting, from three measured points
const at = (w: number[], v: number) => (v <= 100 ? w[0] + ((w[1] - w[0]) * (v - NARROW)) / (100 - NARROW) : w[1] + ((w[2] - w[1]) * (v - 100)) / (PEAK - 100))

const css = (below: number) => `
.lyr-hn{position:relative;width:100%;height:100%;container-type:inline-size;pointer-events:none;color:var(--hn-ink);font-family:var(--hn-font);font-weight:400;-webkit-font-smoothing:antialiased;font-synthesis:none}
.lyr-hn *{box-sizing:border-box}
.lyr-hn-plate{position:absolute;left:calc(var(--hn-px,0px) + var(--hn-sx,0px));right:calc(var(--hn-px,0px) - var(--hn-sx,0px));top:var(--hn-py,0px);bottom:var(--hn-py,0px);background:var(--hn-paper);border-radius:var(--hn-pr,0px);pointer-events:auto}
.lyr-hn-plate::after{content:"";position:absolute;inset:0;border:1px solid var(--hn-line);border-radius:inherit;opacity:var(--hn-pa,0);pointer-events:none}
.lyr-hn-row{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:var(--hn-g,14px);padding:0 var(--hn-edge);white-space:nowrap;transform:translate3d(var(--hn-sx,0px),0,0)}
.lyr-hn-name{flex:none;color:inherit;text-decoration:none;font-size:var(--hn-ns);font-weight:500;letter-spacing:-.015em;line-height:1.2;font-variation-settings:"wdth" var(--hn-wd,${NAME_OPEN});pointer-events:auto;transition:opacity .2s ease}
a.lyr-hn-name:hover{opacity:.62}
.lyr-hn-name:focus-visible{outline:2px solid currentColor;outline-offset:4px}
.lyr-hn-hy{position:relative;flex:1 1 auto;min-width:0;height:var(--hn-h,var(--hn-oh));border-radius:var(--hn-r,var(--hn-corner));background:var(--hn-bar);overflow:hidden;pointer-events:auto;transition:background-color .25s ease}
.lyr-hn-hy::before{content:"";position:absolute;inset:0;background:var(--hn-ink);opacity:calc(1 - var(--hn-mix,1));pointer-events:none}
.lyr-hn-live .lyr-hn-row{padding:0}
.lyr-hn-live .lyr-hn-hy{flex:none;width:var(--hn-w)}
.lyr-hn-links{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:flex;align-items:center;opacity:var(--hn-lo,1)}
.lyr-hn-a{display:inline-flex;align-items:center;height:var(--hn-oh);padding:0 16px;color:var(--hn-on);text-decoration:none;font-size:var(--hn-ls);font-weight:450;letter-spacing:-.01em;line-height:1;font-variation-settings:"wdth" var(--w,${REST});opacity:.78;transition:opacity .2s ease}
.lyr-hn-a:hover,.lyr-hn-a[data-here]{opacity:1}
.lyr-hn-a[data-here]::before,.lyr-hn-bl[data-here]::before{content:"";flex:none;width:.56em;height:.12em;margin-right:.42em;background:currentColor;border-radius:.03em}
.lyr-hn-a:focus-visible{outline:2px solid var(--hn-on);outline-offset:-7px;opacity:1}
.lyr-hn-btn{display:none;position:absolute;inset:0;width:100%;height:100%;place-items:center;margin:0;padding:0;border:0;border-radius:inherit;background:none;color:var(--hn-on);font:inherit;font-size:var(--hn-ls);font-weight:450;letter-spacing:-.01em;line-height:1;cursor:pointer;-webkit-tap-highlight-color:transparent}
.lyr-hn-btn span{grid-area:1/1;transition:opacity .25s ease,transform .35s cubic-bezier(.2,.7,.2,1)}
.lyr-hn-btn .lyr-hn-m2{opacity:0;transform:translateY(7px)}
.lyr-hn-sheet-on .lyr-hn-btn .lyr-hn-m1{opacity:0;transform:translateY(-7px)}
.lyr-hn-sheet-on .lyr-hn-btn .lyr-hn-m2{opacity:1;transform:none}
.lyr-hn-btn:focus-visible{outline:2px solid var(--hn-on);outline-offset:-4px}
.lyr-hn-sheet-on .lyr-hn-hy{background:var(--hn-paper)}
@container (max-width:${below - 1}px){.lyr-hn:not([data-mode]) .lyr-hn-links{display:none}.lyr-hn:not([data-mode]) .lyr-hn-btn{display:grid}}
@container (max-width:599px){.lyr-hn-row{padding:0 min(var(--hn-edge),16px)}}
.lyr-hn[data-mode="menu"] .lyr-hn-links{display:none}
.lyr-hn[data-mode="menu"] .lyr-hn-btn{display:grid}
.lyr-hn-sheet{position:absolute;left:0;top:0;width:100%;height:100vh;height:100lvh;background:var(--hn-bar);color:var(--hn-on);clip-path:inset(var(--hn-ct,0px) var(--hn-cr,0px) var(--hn-cb,0px) var(--hn-cl,0px) round var(--hn-cd,0px));visibility:hidden;pointer-events:none;overflow:hidden}
.lyr-hn-sheet[data-on]{visibility:visible;pointer-events:auto}
.lyr-hn-in{position:absolute;left:0;right:0;top:0;height:100vh;height:100dvh;display:flex;flex-direction:column;justify-content:space-between;gap:28px;padding:calc(var(--hn-bar-h,72px) + 20px) var(--hn-se,16px) max(26px,env(safe-area-inset-bottom));overflow-y:auto;overscroll-behavior:contain}
.lyr-hn-big{list-style:none;margin:0;padding:0;border-bottom:1px solid var(--hn-line)}
.lyr-hn-big li{border-top:1px solid var(--hn-line)}
.lyr-hn-bl{display:flex;align-items:center;padding:.2em 0;color:inherit;text-decoration:none;font-size:clamp(34px,11.5cqw,60px);line-height:1.14;letter-spacing:-.035em;font-variation-settings:"wdth" var(--w,100);opacity:var(--o,1);transform:translate3d(0,var(--y,0px),0);-webkit-tap-highlight-color:transparent}
.lyr-hn-bl:active{opacity:.6}
.lyr-hn-bl:focus-visible{outline:2px solid var(--hn-on);outline-offset:-2px}
.lyr-hn-foot{display:flex;flex-direction:column;gap:6px;font-size:15px;line-height:1.4;letter-spacing:-.01em;opacity:var(--hn-fo,1)}
.lyr-hn-foot a{color:inherit;text-decoration:none}
.lyr-hn-foot span{opacity:.6}
.lyr-hn-probe{position:absolute;left:0;top:0;width:0;height:0;overflow:hidden;visibility:hidden;pointer-events:none;white-space:nowrap;font-size:100px;line-height:1}
.lyr-hn-probe span{display:inline-block;white-space:pre}
.lyr-hn-pn{font-weight:500;letter-spacing:-.015em}
.lyr-hn-pl{font-weight:450;letter-spacing:-.01em}
`

type Item = { label: string; link?: string }
type Props = {
    left: string; right: string; link?: string; links: Item[]; menuLabel: string; closeLabel: string; email: string; note: string
    closeOnScroll: boolean; openOnScrollUp: boolean; menuBelow: number; fontFile?: string; fontFamily: string; ink: string; paper: string; line: string; bar: string; onBar: string
    edge: number; nameSize: number; linkSize: number; openHeight: number; corner: number; shortest: number; longest: number; thickness: number; style?: React.CSSProperties
}

const LINKS: Item[] = [{ label: "Work", link: "/work" }, { label: "About", link: "/about" }, { label: "Services", link: "/services" }, { label: "Journal", link: "/journal" }, { label: "Contact", link: "/contact" }]
const pathOf = (href: string) => { try { return new URL(href, window.location.href).pathname.replace(/\/+$/, "") || "/" } catch { return "" } }

/**
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 72
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight fixed
 * @framerDisableUnlink
 */
export default function HyphenNav(props: Props) {
    const {
        left = "North", right = "Studio", link = "/", links: given = LINKS, menuLabel = "Menu", closeLabel = "Close", email = "hello@example.com", note = "Taking on new work from spring.",
        closeOnScroll = true, openOnScrollUp = true, menuBelow = 700, fontFile, fontFamily = "", ink = "#121211", paper = "#F5F4F0", line = "rgba(18,18,17,0.16)", bar = "#E6E5E0", onBar = "#121211",
        edge = 32, nameSize = 17, linkSize = 15, openHeight = 38, corner = 6, shortest = 9, longest = 56, thickness = 3,
    } = props
    const links: Item[] = (Array.isArray(given) ? given : []).filter(l => l && String(l.label || "").trim())
    const key = links.map(l => l.label).join("|")
    const isStatic = useIsStaticRenderer()
    const [mode, setMode] = React.useState<"fit" | "menu" | undefined>(undefined)
    const [live, setLive] = React.useState(false)
    const [menu, setMenu] = React.useState(false)
    const root = React.useRef<HTMLElement>(null)
    const sheet = React.useRef<HTMLDivElement>(null)
    const probe = React.useRef<HTMLDivElement>(null)
    const eng = React.useRef<any>(null)
    const id = React.useId()
    const font = widthFont(fontFamily, fontFile)
    const stack = `"${font.name}", "Helvetica Neue", Arial, sans-serif`

    React.useEffect(() => {
        if (isStatic) return
        const r = root.current, pb = probe.current, sh = sheet.current
        if (!r || !pb || !sh) return
        const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
        // s: open because we are at the top of the page (follows the scroll). h: opened by hand (pointer, keyboard, scroll up). m: the menu sheet. p: page progress.
        const S: any = { s: 1, sGoal: 1, h: 0, hGoal: 0, hV: 0, m: 0, mGoal: eng.current?.mGoal || 0, mV: 0, p: 0, pGoal: 0, a: 0, mag: [], magGoal: [], here: [], hover: false, focus: false, up: false, tap: false, lastY: window.scrollY, upAcc: 0, px: -1, box: [0, 0, 0, 0], cx: 0, g: null, raf: 0, last: 0, first: true, leave: 0, enter: 0, path: "", live: true }
        eng.current = S
        const anchors = () => Array.from(r.querySelectorAll<HTMLElement>(".lyr-hn-a"))
        const bigs = () => Array.from(r.querySelectorAll<HTMLElement>(".lyr-hn-bl"))

        const markHere = () => {
            S.path = window.location.pathname
            const now = pathOf(window.location.href)
            const test = (a: HTMLElement) => {
                const href = a.getAttribute("href")
                // a link to a part of this page (#work) is not the current page
                const there = href && !href.startsWith("#") ? pathOf(href) : ""
                const on = a.hasAttribute("data-framer-page-link-current") || (!!there && (there === now || (there !== "/" && now.startsWith(there + "/"))))
                if (on) a.setAttribute("data-here", ""); else a.removeAttribute("data-here")
                return on
            }
            S.here = anchors().map(test); bigs().forEach(test)
        }

        const measure = () => {
            const W = r.clientWidth, H = r.clientHeight
            if (!W || !H) return
            const kids = pb.children as any
            if (kids.length < 8 + links.length * 3) return
            const w3 = (i: number) => [0, 1, 2].map(k => kids[i + k].getBoundingClientRect().width / 100)
            const L = w3(0), R = w3(3)
            const e = W < 600 ? Math.min(edge, 16) : edge
            const oh = Math.min(openHeight, H - 16)
            const gapO = 14, gapC = nameSize * 0.34
            const btnW = Math.max(kids[6].getBoundingClientRect().width, kids[7].getBoundingClientRect().width) / 100 * linkSize + 32
            const full = Math.max(btnW, W - 2 * e - (at(L, NAME_OPEN) + at(R, NAME_OPEN)) * nameSize - 2 * gapO)
            const lw = links.map((_, i) => w3(8 + i * 3))
            const rest = lw.map(w => at(w, REST) * linkSize + 32)
            const linksW = rest.reduce((a, b) => a + b, 0)
            const fit = links.length > 0 && W >= menuBelow && linksW + 40 <= full
            S.g = { W, H, e, oh, gapO, gapC, btnW, full, compact: fit ? Math.min(full, linksW + 28) : btnW, L, R, fit, rest, linksW }
            if (S.mag.length !== links.length) { S.mag = links.map(() => 0); S.magGoal = links.map(() => 0) }
            r.style.setProperty("--hn-bar-h", H + "px"); r.style.setProperty("--hn-se", e + "px")
            setMode(fit ? "fit" : "menu"); setLive(true)
            if (fit && S.mGoal) setMenu(false)
            markHere()
        }

        const goals = () => {
            const g = S.g
            if (!g) return
            const y = window.scrollY, max = document.documentElement.scrollHeight - window.innerHeight
            S.pGoal = max > 0 ? clamp(y / max, 0, 1) : 0
            if (S.mGoal) return
            S.sGoal = closeOnScroll ? clamp(1 - (y - 6) / 150, 0, 1) : 1
            S.hGoal = g.fit && (S.hover || S.tap || S.focus || S.up) ? 1 : 0
            const O = 1 - (1 - S.s) * (1 - S.h)
            let run = S.cx - g.linksW / 2
            for (let i = 0; i < S.magGoal.length; i++) {
                const c = run + g.rest[i] / 2
                run += g.rest[i]
                S.magGoal[i] = g.fit && S.px >= 0 && O > 0.8 && !mq.matches ? Math.exp(-Math.pow((S.px - c) / 54, 2) / 2) : 0
            }
        }

        const paint = () => {
            const g = S.g
            if (!g) return
            const set = (k: string, v: string) => r.style.setProperty(k, v)
            // O: how open the hyphen is. X: how far it reaches (1 = the whole bar, at the top of a page; 0 = just round its links)
            const O = clamp(1 - (1 - S.s) * (1 - S.h), 0, 1), X = S.s, M = clamp(S.m, 0, 1), F = O * X
            const wd = lerp(NAME_CLOSED, NAME_OPEN, O), wl = at(g.L, wd) * nameSize, wr = at(g.R, wd) * nameSize
            const gap = lerp(g.gapC, g.gapO, O)
            const closedW = g.fit ? lerp(shortest, longest, S.p) : g.btnW
            const hw = lerp(closedW, lerp(g.compact, g.full, X), O)
            const hh = lerp(g.fit ? thickness : Math.min(30, g.oh), g.oh, O)
            const rad = lerp(g.fit ? Math.min(corner, thickness / 2) : corner, corner, O)
            const rowW = wl + wr + 2 * gap + hw
            // opened by hand, the row grows away from the word under the pointer: S.a is +1 (left word stays), -1 (right word stays) or 0
            const rowC = (at(g.L, NAME_CLOSED) + at(g.R, NAME_CLOSED)) * nameSize + 2 * g.gapC + closedW
            const room = Math.max(0, (g.W - rowW) / 2 - 20)
            const sx = clamp((S.a * (1 - X) * (rowW - rowC)) / 2, -room, room)
            // the paper plate is the whole bar at the top of a page and a chip round the row everywhere else
            const chipH = lerp(Math.min(44, g.H - 12), Math.min(g.oh + 14, g.H - 4), O)
            const px = Math.max(0, (g.W - rowW) / 2 - lerp(16, g.e, F)), py = lerp((g.H - chipH) / 2, 0, F)
            S.box = [px + sx, g.W - px + sx, py, g.H - py]
            S.cx = (g.W - rowW) / 2 + sx + wl + gap + hw / 2
            set("--hn-wd", wd.toFixed(2)); set("--hn-g", gap.toFixed(2) + "px"); set("--hn-w", hw.toFixed(2) + "px"); set("--hn-h", hh.toFixed(2) + "px"); set("--hn-r", rad.toFixed(2) + "px"); set("--hn-sx", sx.toFixed(2) + "px")
            set("--hn-px", px.toFixed(2) + "px"); set("--hn-py", py.toFixed(2) + "px"); set("--hn-pr", lerp(12, 0, F).toFixed(2) + "px"); set("--hn-pa", clamp((1 - F) * 1.6, 0, 1).toFixed(3))
            set("--hn-mix", (g.fit ? clamp(O * 2.4, 0, 1) : 1).toFixed(3))
            const lo = clamp((O - 0.5) / 0.5, 0, 1)
            set("--hn-lo", lo.toFixed(3))
            const box = r.querySelector<HTMLElement>(".lyr-hn-links")
            if (box) { box.style.visibility = lo > 0 ? "visible" : "hidden"; box.style.pointerEvents = O > 0.85 ? "auto" : "none" }
            let top = 0
            for (const v of S.mag) top = Math.max(top, v)
            // the word under the pointer widens and its neighbours give a little
            anchors().forEach((a, i) => { const m = Math.max(S.mag[i] || 0, S.here[i] ? 0.3 : 0); a.style.setProperty("--w", (lerp(86, REST, lo) + ((PEAK - REST) * m - 7 * Math.max(0, top - m)) * lo).toFixed(1)) })
            // the menu sheet opens out of the hyphen
            if (M > 0.002) sh.setAttribute("data-on", ""); else sh.removeAttribute("data-on")
            const x0 = (g.W - rowW) / 2 + sx + wl + gap, y0 = (g.H - hh) / 2, Hs = sh.clientHeight || window.innerHeight
            set("--hn-ct", lerp(y0, 0, M).toFixed(2) + "px"); set("--hn-cl", lerp(x0, 0, M).toFixed(2) + "px"); set("--hn-cr", lerp(g.W - x0 - hw, 0, M).toFixed(2) + "px"); set("--hn-cb", lerp(Hs - y0 - hh, 0, M).toFixed(2) + "px"); set("--hn-cd", lerp(rad, 0, M).toFixed(2) + "px")
            bigs().forEach((a, i) => { const t = clamp((M * 1.5 - 0.45 - i * 0.07) / 0.5, 0, 1); a.style.setProperty("--w", lerp(NARROW + 14, 100, t).toFixed(1)); a.style.setProperty("--o", t.toFixed(3)); a.style.setProperty("--y", ((1 - t) * 14).toFixed(1) + "px") })
            set("--hn-fo", clamp((M - 0.8) / 0.2, 0, 1).toFixed(3))
        }

        const tick = (t: number) => {
            S.raf = 0
            const dt = S.last ? Math.min(64, t - S.last) : 16; S.last = t
            const jump = S.first || mq.matches
            let moving = false
            const ease = (cur: number, goal: number, tau: number, eps = 0.0008) => { let v = jump ? goal : cur + (goal - cur) * (1 - Math.exp(-dt / tau)); if (Math.abs(goal - v) < eps) v = goal; if (v !== goal) moving = true; return v }
            // a critically damped spring: starts gently and settles without a bounce
            const spring = (x: string, v: string, goal: number, k: number) => {
                if (jump) { S[x] = goal; S[v] = 0; return }
                const c = 2 * Math.sqrt(k)
                let left = dt / 1000
                while (left > 0) { const d = Math.min(left, 1 / 120); S[v] += (-k * (S[x] - goal) - c * S[v]) * d; S[x] += S[v] * d; left -= d }
                if (Math.abs(S[x] - goal) < 0.0006 && Math.abs(S[v]) < 0.004) { S[x] = goal; S[v] = 0 } else moving = true
            }
            S.s = ease(S.s, S.sGoal, 70); S.p = ease(S.p, S.pGoal, 140)
            spring("h", "hV", S.hGoal, 210); spring("m", "mV", S.mGoal, 150)
            if (S.h === 0 && !S.hGoal) S.a = 0
            for (let i = 0; i < S.mag.length; i++) S.mag[i] = ease(S.mag[i], S.magGoal[i], 90, 0.004)
            S.first = false
            paint()
            if (moving && S.live) S.raf = requestAnimationFrame(tick); else S.last = 0
        }
        const kick = () => { goals(); if (!S.raf) S.raf = requestAnimationFrame(tick) }
        S.kick = kick
        const full = () => { measure(); kick() }
        // where the pointer is on the closed name decides which word stays put
        const anchorAt = (x: number | null) => {
            const g = S.g
            if (!g || S.h > 0.05) return
            if (x === null) { S.a = 0; return }
            const l = S.box[0] + 16 + at(g.L, NAME_CLOSED) * nameSize, rr = S.box[1] - 16 - at(g.R, NAME_CLOSED) * nameSize
            S.a = x < l + 3 ? 1 : x > rr - 3 ? -1 : 0
        }

        const onScroll = () => {
            const y = window.scrollY, dy = y - S.lastY
            S.lastY = y
            if (dy > 4) { S.up = false; S.tap = false; S.upAcc = 0 } else if (dy < 0) { S.upAcc -= dy; if (openOnScrollUp && S.upAcc > 56 && !S.up) { anchorAt(null); S.up = true } }
            if (window.location.pathname !== S.path) markHere()
            kick()
        }
        const onMove = (ev: PointerEvent) => {
            if (ev.pointerType !== "mouse" || !S.g) return
            const b = r.getBoundingClientRect(), x = ev.clientX - b.left, y = ev.clientY - b.top
            const inside = y >= S.box[2] - 6 && y <= S.box[3] + 6 && x >= S.box[0] - 10 && x <= S.box[1] + 10
            if (inside) {
                if (S.leave) { clearTimeout(S.leave); S.leave = 0 }
                S.px = x
                // a short pause before opening, so a pointer passing through does not set it off
                if (!S.hover && !S.enter) S.enter = window.setTimeout(() => { S.enter = 0; anchorAt(S.px); S.hover = true; kick() }, 70)
                kick()
            } else {
                if (S.enter) { clearTimeout(S.enter); S.enter = 0 }
                if (S.px >= 0) { S.px = -1; kick() }
                if (S.hover && !S.leave) S.leave = window.setTimeout(() => { S.leave = 0; S.hover = false; kick() }, 300)
            }
        }
        const onOut = () => { if (S.leave) clearTimeout(S.leave); if (S.enter) clearTimeout(S.enter); S.leave = 0; S.enter = 0; S.hover = false; S.px = -1; kick() }
        const onDown = (ev: PointerEvent) => { if (S.tap && !r.contains(ev.target as Node)) { S.tap = false; kick() } }
        const onIn = () => { const f = !!r.querySelector(":focus-visible"); if (f && !S.focus) anchorAt(null); S.focus = f; kick() }
        const onBlur = () => window.setTimeout(() => { if (S.live) { S.focus = !!r.querySelector(":focus-visible"); kick() } }, 0)
        S.open = (x?: number) => { if (S.s < 0.5 && S.h < 0.5 && !S.tap) { anchorAt(typeof x === "number" ? x - r.getBoundingClientRect().left : null); S.tap = true; kick() } }

        full()
        const ro = new ResizeObserver(full); ro.observe(r)
        const mo = new MutationObserver(markHere); mo.observe(r, { subtree: true, attributes: true, attributeFilter: ["data-framer-page-link-current", "href"] })
        const fonts = (document as any).fonts
        if (fonts) { fonts.load(`500 100px "${font.name}"`).then(full).catch(() => {}); fonts.ready.then(full); fonts.addEventListener?.("loadingdone", full) }
        window.addEventListener("scroll", onScroll, { passive: true })
        window.addEventListener("pointermove", onMove, { passive: true })
        document.documentElement.addEventListener("mouseleave", onOut)
        document.addEventListener("pointerdown", onDown, { passive: true })
        r.addEventListener("focusin", onIn); r.addEventListener("focusout", onBlur)
        return () => {
            S.live = false; ro.disconnect(); mo.disconnect(); fonts?.removeEventListener?.("loadingdone", full)
            window.removeEventListener("scroll", onScroll); window.removeEventListener("pointermove", onMove)
            document.documentElement.removeEventListener("mouseleave", onOut); document.removeEventListener("pointerdown", onDown)
            r.removeEventListener("focusin", onIn); r.removeEventListener("focusout", onBlur)
            if (S.raf) cancelAnimationFrame(S.raf); if (S.leave) clearTimeout(S.leave); if (S.enter) clearTimeout(S.enter)
        }
    }, [isStatic, left, right, key, menuLabel, closeLabel, closeOnScroll, openOnScrollUp, menuBelow, font.name, font.css, edge, nameSize, linkSize, openHeight, corner, shortest, longest, thickness])

    // the menu sheet: hold the page still while it is open, and close on Escape
    React.useEffect(() => {
        const S = eng.current
        if (S) { S.mGoal = menu ? 1 : 0; S.kick?.() }
        if (!menu) return
        const el = document.documentElement, before = el.style.overflow
        el.style.overflow = "hidden"
        const onKey = (ev: KeyboardEvent) => { if (ev.key === "Escape") setMenu(false) }
        window.addEventListener("keydown", onKey)
        return () => { el.style.overflow = before; window.removeEventListener("keydown", onKey) }
    }, [menu])

    const face = font.css
    const vars: any = { "--hn-ink": ink, "--hn-paper": paper, "--hn-line": line, "--hn-bar": bar, "--hn-on": onBar, "--hn-font": stack, "--hn-edge": edge + "px", "--hn-ns": nameSize + "px", "--hn-ls": linkSize + "px", "--hn-oh": openHeight + "px", "--hn-corner": corner + "px" }
    const wrap = (href: string | undefined, el: React.ReactElement) => (href ? <Link href={href}>{el}</Link> : el)
    const tapOpen = (ev: React.MouseEvent) => eng.current?.open?.(ev.clientX)
    return (
        <nav ref={root} className={"lyr-hn" + (live ? " lyr-hn-live" : "") + (menu ? " lyr-hn-sheet-on" : "")} data-mode={mode} style={{ ...props.style, ...vars }} aria-label="Main">
            <style dangerouslySetInnerHTML={{ __html: face + css(menuBelow) }} />
            <div className="lyr-hn-plate" onClick={tapOpen} />
            <div ref={sheet} className="lyr-hn-sheet" id={id} aria-hidden={!menu}>
                <div className="lyr-hn-in">
                    <ul className="lyr-hn-big">
                        {links.map((l, i) => <li key={i}>{wrap(l.link, <a className="lyr-hn-bl" tabIndex={menu ? 0 : -1} onClick={() => setMenu(false)}>{l.label}</a>)}</li>)}
                    </ul>
                    {email || note ? (
                        <div className="lyr-hn-foot">
                            {email ? <a href={`mailto:${email}`} tabIndex={menu ? 0 : -1}>{email}</a> : null}
                            {note ? <span>{note}</span> : null}
                        </div>
                    ) : null}
                </div>
            </div>
            <div className="lyr-hn-row">
                {wrap(link, <a className="lyr-hn-name" aria-label={`${left} ${right}`.trim()}>{left}</a>)}
                <div className="lyr-hn-hy" onClick={tapOpen}>
                    <div className="lyr-hn-links">
                        {links.map((l, i) => <React.Fragment key={i}>{wrap(l.link, <a className="lyr-hn-a">{l.label}</a>)}</React.Fragment>)}
                    </div>
                    <button type="button" className="lyr-hn-btn" aria-expanded={menu} aria-controls={id} onClick={() => setMenu(v => !v)}>
                        <span className="lyr-hn-m1">{menuLabel}</span>
                        <span className="lyr-hn-m2" aria-hidden="true">{closeLabel}</span>
                    </button>
                </div>
                {wrap(link, <a className="lyr-hn-name" tabIndex={-1} aria-hidden="true">{right}</a>)}
            </div>
            <div ref={probe} className="lyr-hn-probe" aria-hidden="true">
                {[left, right].map((t, i) => [NARROW, 100, PEAK].map(v => <span key={i + "-" + v} className="lyr-hn-pn" style={{ fontVariationSettings: `"wdth" ${v}` }}>{t}</span>))}
                <span className="lyr-hn-pl" style={{ fontVariationSettings: `"wdth" 100` }}>{menuLabel}</span>
                <span className="lyr-hn-pl" style={{ fontVariationSettings: `"wdth" 100` }}>{closeLabel}</span>
                {links.map((l, i) => [NARROW, 100, PEAK].map(v => <span key={"l" + i + "-" + v} className="lyr-hn-pl" style={{ fontVariationSettings: `"wdth" ${v}` }}>{l.label}</span>))}
            </div>
        </nav>
    )
}

addPropertyControls(HyphenNav, {
    left: { type: ControlType.String, title: "Left word", defaultValue: "North", description: "The word before the hyphen." },
    right: { type: ControlType.String, title: "Right word", defaultValue: "Studio", description: "The word after the hyphen." },
    link: { type: ControlType.Link, title: "Name link", description: "Where the name goes. Usually the home page." },
    links: {
        type: ControlType.Array, title: "Links", maxCount: 8,
        control: { type: ControlType.Object, controls: { label: { type: ControlType.String, title: "Label" }, link: { type: ControlType.Link, title: "Link" } } },
        defaultValue: LINKS,
        description: "Shown inside the hyphen. Where they do not fit, the hyphen becomes a menu button.",
    },
    menuLabel: { type: ControlType.String, title: "Menu label", defaultValue: "Menu" },
    closeLabel: { type: ControlType.String, title: "Close label", defaultValue: "Close" },
    email: { type: ControlType.String, title: "Email", defaultValue: "hello@example.com", description: "Shown at the foot of the phone menu." },
    note: { type: ControlType.String, title: "Note", defaultValue: "Taking on new work from spring.", description: "A quiet line under the email." },
    closeOnScroll: { type: ControlType.Boolean, title: "Closes on scroll", defaultValue: true, enabledTitle: "Yes", disabledTitle: "No", description: "Off keeps the hyphen open the whole way down the page." },
    openOnScrollUp: { type: ControlType.Boolean, title: "Opens on scroll up", defaultValue: true, enabledTitle: "Yes", disabledTitle: "No", hidden: (p: any) => p.closeOnScroll === false },
    menuBelow: { type: ControlType.Number, title: "Menu below", defaultValue: 700, min: 0, max: 1400, step: 10, unit: "px", description: "Narrower than this, the links move into the menu." },
    fontFile: { type: ControlType.File, title: "Font file", allowedFileTypes: ["woff2"], description: "A variable font with a width axis. The words narrow and widen with it. Leave empty for Mona Sans from Google Fonts." },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "", placeholder: "Mona Sans", description: "A family with a width axis that the site already loads. Leave empty to use the font file, or Mona Sans." },
    bar: { type: ControlType.Color, title: "Bar colour", defaultValue: "#E6E5E0", description: "The open hyphen, and the phone menu it opens into." },
    onBar: { type: ControlType.Color, title: "Bar text colour", defaultValue: "#121211", description: "Links and labels on the bar." },
    ink: { type: ControlType.Color, title: "Ink colour", defaultValue: "#121211", description: "The name and the closed hyphen." },
    paper: { type: ControlType.Color, title: "Paper colour", defaultValue: "#F5F4F0" },
    line: { type: ControlType.Color, title: "Line colour", defaultValue: "rgba(18,18,17,0.16)" },
    edge: { type: ControlType.Number, title: "Side gap", defaultValue: 32, min: 0, max: 80, step: 1, unit: "px" },
    nameSize: { type: ControlType.Number, title: "Name size", defaultValue: 17, min: 12, max: 28, step: 1, unit: "px" },
    linkSize: { type: ControlType.Number, title: "Link size", defaultValue: 15, min: 12, max: 22, step: 1, unit: "px" },
    openHeight: { type: ControlType.Number, title: "Hyphen height", defaultValue: 38, min: 24, max: 64, step: 1, unit: "px", description: "How tall the hyphen is when it is open." },
    corner: { type: ControlType.Number, title: "Corner radius", defaultValue: 6, min: 0, max: 32, step: 1, unit: "px" },
    thickness: { type: ControlType.Number, title: "Closed thickness", defaultValue: 3, min: 1, max: 8, step: 1, unit: "px" },
    shortest: { type: ControlType.Number, title: "Closed, page top", defaultValue: 9, min: 4, max: 80, step: 1, unit: "px", description: "Length of the closed hyphen at the top of a page." },
    longest: { type: ControlType.Number, title: "Closed, page end", defaultValue: 56, min: 4, max: 160, step: 1, unit: "px", description: "Its length at the end of the page. It grows as you scroll." },
})

In this preview

The photos are demo pictures shown on this site only; they are not in the files you copy. Set your own through the image props.