{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tilt-menu","type":"registry:component","title":"Tilt Menu","description":"The page swings aside and tilts to show a full menu behind it.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/tilt-menu/tilt-menu.tsx","type":"registry:component","target":"components/dimaac/tilt-menu.tsx","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState, useSyncExternalStore, type KeyboardEvent, type ReactNode } from \"react\";\nimport { gsap } from \"gsap\";\nimport { useGSAP } from \"@gsap/react\";\nimport { CustomEase } from \"gsap/CustomEase\";\nimport { SplitText } from \"gsap/SplitText\";\n\n// A full-screen menu that sits behind the page. Opening it swings the page aside and tilts it,\n// like a door, so the menu shows in the gap. Every label rolls up letter by letter on hover:\n// each label is written twice, one copy on top of the other, and the letters of the top copy\n// slide up out of their masks while the letters of the copy below slide in.\n\nif (typeof window !== \"undefined\") {\n  gsap.registerPlugin(useGSAP, CustomEase, SplitText);\n  // The swing: slow start, quick middle, soft landing\n  if (!CustomEase.get(\"tiltMenu\")) CustomEase.create(\"tiltMenu\", \"M0,0 C0.55,0 0.45,1 1,1\");\n}\n\nexport type TiltMenuLink = {\n  /** The text of the link */\n  label: string;\n  /** Where it goes */\n  href: string;\n  /** A photo that opens next to the label on hover. Decorative, so it has no alt text. */\n  image?: string;\n};\n\nexport type TiltMenuProps = {\n  /** The menu's links, top first. Three to six short words look best. */\n  links: TiltMenuLink[];\n  /** How far the page tilts when the menu is open, in degrees. */\n  tilt?: number;\n  /** How far the page slides aside when the menu is open, as a share of its width (50 = half). */\n  shift?: number;\n  /** How long the page takes to swing open or shut, in seconds. */\n  duration?: number;\n  /** The side the menu opens on. The page swings away to the other side. */\n  side?: \"left\" | \"right\";\n  /** Opens the menu from outside. Leave it out and the component keeps its own state. */\n  open?: boolean;\n  /** Whether the menu starts open (only when `open` is left out). */\n  defaultOpen?: boolean;\n  /** Called with the new state when the menu opens or closes. */\n  onOpenChange?: (open: boolean) => void;\n  /** The logo or name at the top of the page, next to the menu button. */\n  brand?: ReactNode;\n  /** Text on the button that opens the menu. */\n  menuLabel?: string;\n  /** Text on the button that closes it. */\n  closeLabel?: string;\n  /** Small print at the bottom of the menu, e.g. an email address. */\n  menuFooter?: ReactNode;\n  /** Extra classes for the outer box. Give it a size (it fills it). */\n  className?: string;\n  /** Classes for the page: its background and text colour. */\n  pageClassName?: string;\n  /** Classes for the menu: its background and text colour. */\n  menuClassName?: string;\n  /** Classes for each link: its font and size. */\n  linkClassName?: string;\n  /** Classes for the menu and close buttons. */\n  buttonClassName?: string;\n  /** The page itself. It scrolls inside the box. */\n  children?: ReactNode;\n};\n\n// prefers-reduced-motion, live\nfunction subscribeToMotion(onChange: () => void) {\n  const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n}\nconst prefersReducedMotion = () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n// A label written twice for the roll. Screen readers read the hidden copy once; the two visible\n// copies are aria-hidden. The bottom copy stays invisible until its letters are split and moved down.\nfunction RollText({ label }: { label: string }) {\n  return (\n    <span data-roll className=\"relative inline-block whitespace-nowrap\">\n      <span className=\"sr-only\">{label}</span>\n      <span data-roll-front aria-hidden=\"true\" className=\"inline-block\">\n        {label}\n      </span>\n      <span data-roll-back aria-hidden=\"true\" className=\"absolute top-0 left-0 inline-block\" style={{ visibility: \"hidden\" }}>\n        {label}\n      </span>\n    </span>\n  );\n}\n\nexport function TiltMenu({\n  links,\n  tilt = 12,\n  shift = 50,\n  duration = 0.77,\n  side = \"right\",\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  brand,\n  menuLabel = \"Menu\",\n  closeLabel = \"Close\",\n  menuFooter,\n  className = \"\",\n  pageClassName = \"bg-white text-black dark:bg-neutral-950 dark:text-white\",\n  menuClassName = \"bg-black text-white dark:bg-[#efede8] dark:text-black\",\n  linkClassName = \"text-[clamp(2.5rem,8vw,4.5rem)] leading-[0.95] font-bold tracking-[-0.02em] uppercase\",\n  buttonClassName = \"text-[1.5rem] leading-none uppercase\",\n  children,\n}: TiltMenuProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const pageRef = useRef<HTMLDivElement>(null);\n  const navRef = useRef<HTMLElement>(null);\n  const toggleRef = useRef<HTMLButtonElement>(null);\n  const closeRef = useRef<HTMLButtonElement>(null);\n  const menuId = useId();\n  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);\n\n  // Controlled when `open` is passed, otherwise the component keeps its own state\n  const [ownOpen, setOwnOpen] = useState(defaultOpen);\n  const isOpen = open ?? ownOpen;\n  const onOpenChangeRef = useRef(onOpenChange);\n  onOpenChangeRef.current = onOpenChange;\n  const setOpen = (next: boolean) => {\n    if (open === undefined) setOwnOpen(next);\n    onOpenChangeRef.current?.(next);\n  };\n  const setOpenRef = useRef(setOpen);\n  setOpenRef.current = setOpen;\n\n  // +1 when the menu is on the right (the page swings left), -1 when it's on the left\n  const direction = side === \"right\" ? 1 : -1;\n\n  // The rolls. Built again only when the labels change, not on every render.\n  const labelsKey = [menuLabel, closeLabel, ...links.map((link) => `${link.label} ${link.image ?? \"\"}`)].join(\"|\");\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      if (!root) return;\n      const cleanups: (() => void)[] = [];\n\n      root.querySelectorAll<HTMLElement>(\"[data-roll]\").forEach((roll) => {\n        const front = roll.querySelector<HTMLElement>(\"[data-roll-front]\");\n        const back = roll.querySelector<HTMLElement>(\"[data-roll-back]\");\n        const trigger = roll.closest<HTMLElement>(\"a, button\") ?? roll;\n        const thumb = trigger.querySelector<HTMLElement>(\"[data-thumb]\");\n        if (!front || !back) return;\n\n        // The photo opens out of the label: from its right edge when it sits left of the label\n        const hiddenClip = trigger.dataset.side === \"right\" ? \"inset(0% 0% 0% 100%)\" : \"inset(0% 100% 0% 0%)\";\n        let play: () => void;\n        let reverse: () => void;\n\n        if (reducedMotion) {\n          // No roll: the photo just shows while the link is hovered or focused\n          if (thumb) gsap.set(thumb, { clipPath: hiddenClip });\n          play = () => thumb && gsap.set(thumb, { clipPath: \"inset(0% 0% 0% 0%)\" });\n          reverse = () => thumb && gsap.set(thumb, { clipPath: hiddenClip });\n        } else {\n          const outgoing = SplitText.create(front, { type: \"chars\", mask: \"chars\", aria: \"none\" });\n          const incoming = SplitText.create(back, { type: \"chars\", mask: \"chars\", aria: \"none\" });\n          gsap.set(incoming.chars, { yPercent: 100 });\n          gsap.set(back, { visibility: \"visible\" });\n\n          const label = front.textContent ?? \"\";\n          const roll = gsap.timeline({\n            paused: true,\n            defaults: { duration: 0.45, ease: \"power3.inOut\", stagger: Math.min(0.022, 0.25 / Math.max(label.length, 1)) },\n          });\n          roll.to(outgoing.chars, { yPercent: -100 }).to(incoming.chars, { yPercent: 0 }, 0);\n          if (thumb) {\n            roll.fromTo(thumb, { clipPath: hiddenClip }, { clipPath: \"inset(0% 0% 0% 0%)\", duration: 0.6, ease: \"power3.out\", stagger: 0 }, 0);\n            roll.fromTo(thumb.firstElementChild, { scale: 1.35 }, { scale: 1, duration: 0.7, ease: \"power3.out\", stagger: 0 }, 0);\n          }\n          play = () => roll.play();\n          reverse = () => roll.reverse();\n          cleanups.push(() => {\n            roll.kill();\n            outgoing.revert();\n            incoming.revert();\n          });\n        }\n\n        // Keyboard focus rolls it too, but not the focus a click leaves behind\n        const onFocus = () => trigger.matches(\":focus-visible\") && play();\n        trigger.addEventListener(\"mouseenter\", play);\n        trigger.addEventListener(\"mouseleave\", reverse);\n        trigger.addEventListener(\"focus\", onFocus);\n        trigger.addEventListener(\"blur\", reverse);\n        cleanups.push(() => {\n          trigger.removeEventListener(\"mouseenter\", play);\n          trigger.removeEventListener(\"mouseleave\", reverse);\n          trigger.removeEventListener(\"focus\", onFocus);\n          trigger.removeEventListener(\"blur\", reverse);\n        });\n      });\n\n      return () => cleanups.forEach((cleanup) => cleanup());\n    },\n    { scope: rootRef, dependencies: [labelsKey, side, reducedMotion], revertOnUpdate: true },\n  );\n\n  // The swing. A new tilt, shift or side while the menu is open moves the page straight there.\n  const firstSwing = useRef(true);\n  const wasOpen = useRef(isOpen);\n  useGSAP(\n    () => {\n      const page = pageRef.current;\n      const nav = navRef.current;\n      if (!page || !nav) return;\n      const items = nav.querySelectorAll<HTMLElement>(\"[data-menu-item]\");\n      const instant = firstSwing.current;\n      const toggled = wasOpen.current !== isOpen;\n      firstSwing.current = false;\n      wasOpen.current = isOpen;\n\n      if (reducedMotion) {\n        // No swing: the page fades out over the menu, and back in\n        gsap.set(page, { xPercent: 0, rotation: 0, borderRadius: 0 });\n        gsap.set(items, { x: 0, autoAlpha: 1 });\n        const fade = instant ? 0 : 0.25;\n        if (isOpen) {\n          // Hidden only once the fade ends, so the faded page can't be clicked or focused\n          gsap.to(page, { opacity: 0, duration: fade, ease: \"none\", overwrite: true, onComplete: () => gsap.set(page, { visibility: \"hidden\" }) });\n        } else {\n          // Visible straight away (this runs before the focus effect), so focus can go back to the menu button\n          gsap.set(page, { visibility: \"visible\" });\n          gsap.to(page, { opacity: 1, duration: fade, ease: \"none\", overwrite: true });\n        }\n      } else {\n        gsap.set(page, { opacity: 1, visibility: \"visible\" });\n        gsap.to(page, {\n          xPercent: isOpen ? -direction * shift : 0,\n          rotation: isOpen ? direction * tilt : 0,\n          // The corners round off as it leaves, so it reads as a card lifted off the menu\n          borderRadius: isOpen ? 18 : 0,\n          duration: instant ? 0 : duration,\n          ease: \"tiltMenu\",\n          overwrite: true,\n        });\n\n        if (instant) gsap.set(items, { x: 0, autoAlpha: isOpen ? 1 : 0 });\n        else if (toggled && isOpen) {\n          // The links follow the page in from the menu's side, one after another\n          gsap.fromTo(\n            items,\n            { x: direction * 70, autoAlpha: 0 },\n            { x: 0, autoAlpha: 1, duration: duration * 1.1, delay: duration * 0.22, ease: \"power3.out\", stagger: 0.055, overwrite: true },\n          );\n        } else if (toggled) {\n          gsap.to(items, { x: direction * 30, autoAlpha: 0, duration: duration * 0.55, ease: \"power2.in\", overwrite: true });\n        }\n      }\n\n      return () => {\n        // Unmounting (or React's StrictMode remount) starts over without a swing\n        firstSwing.current = true;\n      };\n    },\n    { scope: rootRef, dependencies: [isOpen, tilt, shift, duration, direction, reducedMotion] },\n  );\n\n  // Focus goes into the menu when the menu button opens it, and back to the button when it closes.\n  // An open from outside (the `open` prop) leaves focus where it is; Tab still lands in the menu,\n  // because the page is inert while the menu is open.\n  const lastOpen = useRef(isOpen);\n  const openedByButton = useRef(false);\n  useEffect(() => {\n    if (lastOpen.current === isOpen) return;\n    lastOpen.current = isOpen;\n    if (isOpen) {\n      if (openedByButton.current) closeRef.current?.focus({ preventScroll: true });\n      openedByButton.current = false;\n    } else {\n      const active = document.activeElement;\n      if (!active || active === document.body || navRef.current?.contains(active)) toggleRef.current?.focus({ preventScroll: true });\n    }\n  }, [isOpen]);\n\n  // Escape closes the menu, wherever focus is\n  useEffect(() => {\n    if (!isOpen) return;\n    const onKeyDown = (event: globalThis.KeyboardEvent) => {\n      if (event.key === \"Escape\") setOpenRef.current(false);\n    };\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => document.removeEventListener(\"keydown\", onKeyDown);\n  }, [isOpen]);\n\n  // Tab and Shift+Tab go round the menu instead of leaving it\n  const trapFocus = (event: KeyboardEvent<HTMLElement>) => {\n    if (event.key !== \"Tab\" || !navRef.current) return;\n    const focusable = Array.from(navRef.current.querySelectorAll<HTMLElement>(\"a[href], button:not([disabled])\"));\n    const first = focusable[0];\n    const last = focusable[focusable.length - 1];\n    if (!first || !last) return;\n    if (event.shiftKey && document.activeElement === first) {\n      event.preventDefault();\n      last.focus();\n    } else if (!event.shiftKey && document.activeElement === last) {\n      event.preventDefault();\n      first.focus();\n    }\n  };\n\n  const menuSide = side === \"right\" ? \"right-[30px] text-right\" : \"left-[30px] text-left\";\n\n  return (\n    <div ref={rootRef} className={`relative h-full w-full overflow-hidden ${className}`}>\n      <nav id={menuId} ref={navRef} aria-label=\"Menu\" inert={!isOpen} onKeyDown={trapFocus} className={`absolute inset-0 ${menuClassName}`}>\n        <button ref={closeRef} type=\"button\" onClick={() => setOpen(false)} className={`absolute top-7 z-10 cursor-pointer border-0 bg-transparent p-0 [color:inherit] outline-offset-4 focus-visible:outline-2 focus-visible:outline-current ${menuSide} ${buttonClassName}`}>\n          <RollText label={closeLabel} />\n        </button>\n\n        <ul className={`absolute inset-0 m-0 flex list-none flex-col justify-center gap-2 p-0 px-[30px] ${side === \"right\" ? \"items-end\" : \"items-start\"}`}>\n          {links.map((link) => (\n            <li key={`${link.label}-${link.href}`} data-menu-item>\n              <a\n                href={link.href}\n                data-side={side}\n                onClick={() => setOpen(false)}\n                className={`flex items-center no-underline outline-offset-4 [color:inherit] focus-visible:outline-2 focus-visible:outline-current motion-reduce:transition-opacity motion-reduce:hover:opacity-60 ${side === \"right\" ? \"flex-row\" : \"flex-row-reverse\"} ${linkClassName}`}\n              >\n                {link.image ? (\n                  <span data-thumb aria-hidden=\"true\" className=\"mx-[0.14em] inline-block h-[0.78em] w-[1.25em] shrink-0 overflow-hidden rounded-[0.08em]\" style={{ clipPath: side === \"right\" ? \"inset(0% 0% 0% 100%)\" : \"inset(0% 100% 0% 0%)\" }}>\n                    <img src={link.image} alt=\"\" draggable={false} decoding=\"async\" className=\"block h-full w-full object-cover\" />\n                  </span>\n                ) : null}\n                <RollText label={link.label} />\n              </a>\n            </li>\n          ))}\n        </ul>\n\n        {menuFooter ? (\n          <div data-menu-item className={`absolute bottom-[30px] ${menuSide}`}>\n            {menuFooter}\n          </div>\n        ) : null}\n      </nav>\n\n      <div ref={pageRef} className=\"absolute inset-0 z-10 overflow-hidden shadow-[0_40px_120px_-20px_rgba(0,0,0,0.55)]\" style={{ willChange: \"transform\" }}>\n        <div inert={isOpen} className={`h-full w-full overflow-y-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${pageClassName}`}>\n          <header className=\"sticky top-0 z-10 flex items-center justify-between gap-6 bg-inherit px-[30px] py-7\">\n            <div className=\"min-w-0\">{brand}</div>\n            <button\n              ref={toggleRef}\n              type=\"button\"\n              aria-expanded={isOpen}\n              aria-controls={menuId}\n              onClick={() => {\n                openedByButton.current = true;\n                setOpen(true);\n              }}\n              className={`cursor-pointer border-0 bg-transparent p-0 [color:inherit] outline-offset-4 focus-visible:outline-2 focus-visible:outline-current ${buttonClassName}`}\n            >\n              <RollText label={menuLabel} />\n            </button>\n          </header>\n          {children}\n        </div>\n        {/* While the menu is open, a click on the tilted page closes it (the page itself is inert) */}\n        {isOpen && !reducedMotion ? <div aria-hidden=\"true\" onClick={() => setOpen(false)} className=\"absolute inset-0 cursor-pointer\" /> : null}\n      </div>\n    </div>\n  );\n}\n"}],"categories":["navigation","gsap","splittext","menu","hover","fullscreen"],"docs":"import { TiltMenu } from \"@/components/dimaac/tilt-menu\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/tilt-menu","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/tilt-menu","credits":[{"by":"Amino","url":"https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Benginur Hajjaj","url":"https://www.lummi.ai/photo/calm-portrait-d1zqo","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Steph Meade","url":"https://www.lummi.ai/photo/expressive-minimal-portrait-jrfyc","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/vibrant-woman-portrait-htofy","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"studiomiklus","url":"https://www.lummi.ai/photo/striking-portrait-jodiu","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Shaun Duval","url":"https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Lloyd Creates","url":"https://www.lummi.ai/photo/poised-portrait-ooi9b","what":"Portrait","source":"Lummi","licence":"Lummi License"}]}}