"use client"; import { type AnimationPlaybackControls, animate, AnimatePresence, motion, type MotionValue, useInView, useMotionValue, useReducedMotion, useSpring, useTransform, } from "motion/react"; import { type PointerEvent, type RefObject, useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export type NightOwlHue = "violet" | "indigo" | "sky" | "teal" | "amber" | "orange" | "rose" | "emerald"; export type NightOwlMode = "auto" | "awake" | "sleepy"; const hues: Record = { violet: "[--hue-hi:var(--color-violet-300)] [--hue:var(--color-violet-400)] [--hue-deep:var(--color-violet-600)] [--soft:var(--color-violet-100)] [--ink:var(--color-violet-950)]", indigo: "[--hue-hi:var(--color-indigo-300)] [--hue:var(--color-indigo-400)] [--hue-deep:var(--color-indigo-600)] [--soft:var(--color-indigo-100)] [--ink:var(--color-indigo-950)]", sky: "[--hue-hi:var(--color-sky-300)] [--hue:var(--color-sky-400)] [--hue-deep:var(--color-sky-600)] [--soft:var(--color-sky-100)] [--ink:var(--color-sky-950)]", teal: "[--hue-hi:var(--color-teal-300)] [--hue:var(--color-teal-400)] [--hue-deep:var(--color-teal-600)] [--soft:var(--color-teal-100)] [--ink:var(--color-teal-950)]", amber: "[--hue-hi:var(--color-amber-300)] [--hue:var(--color-amber-400)] [--hue-deep:var(--color-amber-600)] [--soft:var(--color-amber-100)] [--ink:var(--color-amber-950)]", orange: "[--hue-hi:var(--color-orange-300)] [--hue:var(--color-orange-400)] [--hue-deep:var(--color-orange-600)] [--soft:var(--color-orange-100)] [--ink:var(--color-orange-950)]", rose: "[--hue-hi:var(--color-rose-300)] [--hue:var(--color-rose-400)] [--hue-deep:var(--color-rose-600)] [--soft:var(--color-rose-100)] [--ink:var(--color-rose-950)]", emerald: "[--hue-hi:var(--color-emerald-300)] [--hue:var(--color-emerald-400)] [--hue-deep:var(--color-emerald-600)] [--soft:var(--color-emerald-100)] [--ink:var(--color-emerald-950)]", }; export interface NightOwlProps { name?: string; hue?: NightOwlHue; mode?: NightOwlMode; awakeTitle?: string; awakeDescription?: string; sleepyTitle?: string; sleepyDescription?: string; onHoot?: () => void; className?: string; } const VB = { x: 20, y: 24, w: 360, h: 212 }; const FACE = { x: 200, y: 130 }; const EYE_R = 16; const LID = 36; const stars = [ { x: 70, y: 58, s: 1, delay: 0 }, { x: 112, y: 96, s: 0.65, delay: 1.1 }, { x: 254, y: 46, s: 0.7, delay: 0.5 }, { x: 290, y: 112, s: 0.8, delay: 1.7 }, { x: 356, y: 128, s: 0.6, delay: 0.8 }, { x: 48, y: 136, s: 0.55, delay: 2.2 }, ]; const zs = [ { size: 12, x: 260, y: 96, delay: 0 }, { size: 16, x: 272, y: 80, delay: 0.9 }, { size: 20, x: 282, y: 64, delay: 1.8 }, ]; const chevrons = [ [188, 168], [212, 168], [176, 182], [200, 182], [224, 182], [188, 196], [212, 196], ]; const STAR = "M0 -6 Q1 -1 6 0 Q1 1 0 6 Q-1 1 -6 0 Q-1 -1 0 -6 Z"; function useCalmMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } function useBlink(active: boolean, slow: boolean) { const open = useMotionValue(1); useEffect(() => { if (!active) { open.set(1); return; } let timer: ReturnType; let blink: AnimationPlaybackControls | undefined; const schedule = () => { timer = setTimeout( () => { const twice = !slow && Math.random() < 0.2; blink = animate(open, twice ? [1, 0, 1, 0, 1] : [1, 0, 1], { duration: slow ? 0.9 : twice ? 0.42 : 0.2, ease: "easeInOut", times: twice ? [0, 0.18, 0.5, 0.68, 1] : slow ? [0, 0.45, 1] : [0, 0.38, 1], }); schedule(); }, slow ? 3200 + Math.random() * 3000 : 2400 + Math.random() * 3600, ); }; schedule(); return () => { clearTimeout(timer); blink?.stop(); }; }, [active, slow, open]); return open; } function useDarkAncestor(ref: RefObject) { const [dark, setDark] = useState(null); useEffect(() => { const el = ref.current; if (!el) return; const read = () => setDark(!!el.closest(".dark")); read(); const observer = new MutationObserver(read); observer.observe(document.documentElement, { attributeFilter: ["class"], attributes: true }); observer.observe(document.body, { attributeFilter: ["class"], attributes: true }); return () => observer.disconnect(); }, [ref]); return dark; } function Eye({ cx, uid, lookX, lookY, cover, }: { cx: number; uid: string; lookX: MotionValue; lookY: MotionValue; cover: MotionValue; }) { const id = `${uid}-eye-${cx}`; return ( ); } export function NightOwl({ name = "Ollie", hue = "violet", mode = "auto", awakeTitle = "Working late?", awakeDescription = "The night shift is better with company.", sleepyTitle, sleepyDescription = "Owls nap through the day. Switch to dark mode to wake him.", onHoot, className, }: NightOwlProps) { const uid = useId().replace(/:/g, ""); const reduce = useCalmMotion(); const root = useRef(null); const svg = useRef(null); const inView = useInView(root); const live = inView && !reduce; const dark = useDarkAncestor(root); const awake = mode === "awake" || (mode === "auto" && dark !== false); const [tracking, setTracking] = useState(false); const [hoot, setHoot] = useState(0); const [startled, setStartled] = useState(false); const alert = awake || startled; const gazeX = useMotionValue(0); const gazeY = useMotionValue(0); const lookX = useSpring(gazeX, { stiffness: 200, damping: 24, mass: 0.7 }); const lookY = useSpring(gazeY, { stiffness: 200, damping: 24, mass: 0.7 }); const pupilX = useTransform(lookX, (v) => v * 6.5); const pupilY = useTransform(lookY, (v) => v * 5.5); const tilt = useMotionValue(0); const droop = useMotionValue(0); const headRotate = useTransform([tilt, lookX] as const, ([t, l]: number[]) => t + l * 5); const heaviness = useMotionValue(0); const open = useBlink(live, !alert); const cover = useTransform([heaviness, open] as const, ([h, o]: number[]) => (h + (1 - h) * (1 - o)) * (EYE_R * 2 + 2)); const puff = useMotionValue(1); const first = useRef(true); const startleTimer = useRef>(undefined); useEffect(() => () => clearTimeout(startleTimer.current), []); useEffect(() => { if (mode === "auto" && dark === null) return; const target = alert ? 0 : 0.58; if (first.current || reduce) { first.current = false; heaviness.set(target); droop.set(alert ? 0 : 1); return; } const lids = animate(heaviness, target, { duration: alert ? 0.35 : 1.4, ease: [0.65, 0, 0.35, 1] }); const head = animate(droop, alert ? 0 : 1, { duration: alert ? 0.4 : 1.6, ease: [0.65, 0, 0.35, 1] }); return () => { lids.stop(); head.stop(); }; }, [alert, dark, mode, reduce, heaviness, droop]); useEffect(() => { if (!live || !awake) { animate(tilt, 0, { duration: 0.8, ease: [0.65, 0, 0.35, 1] }); return; } let timer: ReturnType; let move: AnimationPlaybackControls | undefined; const curious = () => { const side = Math.random() < 0.5 ? -1 : 1; move = animate(tilt, side * (9 + Math.random() * 6), { duration: 1.1, ease: [0.65, 0, 0.35, 1] }); timer = setTimeout(() => { move = animate(tilt, 0, { duration: 0.9, ease: [0.65, 0, 0.35, 1] }); timer = setTimeout(curious, 3200 + Math.random() * 3600); }, 1500 + Math.random() * 900); }; timer = setTimeout(curious, 2200); return () => { clearTimeout(timer); move?.stop(); }; }, [live, awake, tilt]); useEffect(() => { if (!live || tracking) { if (!live) { gazeX.set(0); gazeY.set(0); } return; } let timer: ReturnType; const look = () => { const centre = Math.random() < 0.4; gazeX.set(centre ? 0 : (Math.random() * 2 - 1) * 0.8); gazeY.set(centre ? 0 : (Math.random() * 2 - 1) * 0.5); timer = setTimeout(look, awake ? 1500 + Math.random() * 2500 : 3000 + Math.random() * 3000); }; timer = setTimeout(look, 900); return () => clearTimeout(timer); }, [live, tracking, awake, gazeX, gazeY]); const follow = (e: PointerEvent) => { if (e.pointerType === "touch" || reduce) return; const box = svg.current?.getBoundingClientRect(); if (!box?.width) return; const unit = box.width / VB.w; const dx = (e.clientX - box.left) / unit + VB.x - FACE.x; const dy = (e.clientY - box.top) / unit + VB.y - FACE.y; const reach = Math.hypot(dx, dy, 70); gazeX.set(dx / reach); gazeY.set(dy / reach); setTracking(true); }; const release = () => { setTracking(false); gazeX.set(0); gazeY.set(0); }; const hootNow = () => { setHoot((h) => h + 1); setStartled(true); clearTimeout(startleTimer.current); startleTimer.current = setTimeout(() => setStartled(false), 1800); onHoot?.(); if (!reduce) animate(puff, [1, 1.06, 0.98, 1], { duration: 0.55, ease: "easeOut", times: [0, 0.35, 0.7, 1] }); }; const headY = useTransform(droop, (d) => d * 4); const headDroop = useTransform([headRotate, droop] as const, ([r, d]: number[]) => r + d * 7); const sky = (night: boolean) => mode === "auto" ? (night ? "opacity-0 dark:opacity-100" : "opacity-100 dark:opacity-0") : night === awake ? "opacity-100" : "opacity-0"; const title = awake ? awakeTitle : (sleepyTitle ?? `${name} is fast asleep`); const description = awake ? awakeDescription : sleepyDescription; return (

{title}

{description}

); }