The Coline design system: accessible React components for Coline Apps, themed live by the host workspace, and for Coline Sites in light and dark. Tailwind v4 stylesheet included. MIT.
| 5 | import { createSuperHover, type SuperHoverEventDetail } from "super-hover"; |
| 6 |
| 7 | /** |
| 8 | * Sliding hover pill shared by DropdownMenu and ContextMenu. Items report |
| 9 | * hover/focus through {@link MenuHighlightContext}; the pill glides between |
| 10 | * them. Flat neutral fill — no border, no shadow, no lighting. |
| 11 | * |
| 12 | * Pointer events alone are not enough: browsers deliberately stop updating |
| 13 | * `:hover` while a list scrolls, so in a scrollable menu the pill would stay |
| 14 | * pinned to whichever row the pointer last physically crossed. `super-hover` |
| 15 | * hit-tests under the pointer every frame, so the pill follows the row that is |
| 16 | * actually beneath the cursor as the list moves under it. |
| 17 | */ |
| 18 |
| 19 | /** Marks a menu row as a hit-test target for the per-frame hover tracker. */ |
| 20 | export const SUPER_HOVER_ATTR = { "data-super-hover": "" } as const; |
| 21 |
| 22 | export const MenuHighlightContext = React.createContext<{ |
| 23 | reportHover: (element: HTMLElement) => void; |
| 24 | }>({ reportHover: () => {} }); |
| 25 |
| 26 | /** The flat pill glides with both edges together. */ |
| 27 | const GLIDE: Transition = { type: "spring", bounce: 0, duration: 0.15 }; |
| 28 | /** |
| 29 | * On Liquid Glass each edge rides its own spring: the leading edge runs ahead |
| 30 | * and the trailing one catches up, so the pill stretches toward the row it is |
| 31 | * heading for and gathers back up as it lands. |
| 32 | */ |
| 33 | const LIQUID_LEAD: Transition = { type: "spring", stiffness: 700, damping: 44 }; |
| 34 | const LIQUID_TRAIL: Transition = { type: "spring", stiffness: 320, damping: 30 }; |
| 35 |
| 36 | export function useMenuHighlight( |
| 37 | contentRef: React.RefObject<HTMLDivElement | null>, |
| 38 | liquid = false, |
| 39 | ) { |
| 40 | const top = useMotionValue(0); |
| 41 | const bottom = useMotionValue(0); |
| 42 | const height = useTransform(() => bottom.get() - top.get()); |
| 43 | const [visible, setVisible] = React.useState(false); |
| 44 | /** |
| 45 | * The pill snaps into place the first time it appears and glides thereafter. |
| 46 | * That decision is made when hover is reported — never read during render, |
| 47 | * since a ref read at render time can leave the transition stale. |
| 48 | */ |
| 49 | const [instant, setInstant] = React.useState(true); |
| 50 | const visibleRef = React.useRef(false); |
| 51 | const nextAppearIsInstantRef = React.useRef(true); |
| 52 | const resetTimerRef = React.useRef<number | null>(null); |
| 53 |
| 54 | const reportHover = React.useCallback( |
| 55 | (element: HTMLElement) => { |
| 56 | const content = contentRef.current; |
| 57 | if (!content) return; |
| 58 | const contentRect = content.getBoundingClientRect(); |
| 59 | const itemRect = element.getBoundingClientRect(); |
| 60 | const nextTop = itemRect.top - contentRect.top + content.scrollTop; |
| 61 | const nextBottom = nextTop + itemRect.height; |
| 62 |
| 63 | if (resetTimerRef.current !== null) { |
| 64 | window.clearTimeout(resetTimerRef.current); |
| 65 | resetTimerRef.current = null; |
| 66 | } |
| 67 |
| 68 | // Appearing from hidden snaps; moving between rows glides. |
| 69 | const instantMove = visibleRef.current ? false : nextAppearIsInstantRef.current; |
| 70 | if (instantMove) { |
| 71 | top.jump(nextTop); |
| 72 | bottom.jump(nextBottom); |
| 73 | } else { |
| 74 | const down = nextTop > top.get(); |
| 75 | animate(top, nextTop, liquid ? (down ? LIQUID_TRAIL : LIQUID_LEAD) : GLIDE); |
| 76 | animate(bottom, nextBottom, liquid ? (down ? LIQUID_LEAD : LIQUID_TRAIL) : GLIDE); |
| 77 | } |
| 78 | setInstant(instantMove); |
| 79 | nextAppearIsInstantRef.current = false; |
| 80 | visibleRef.current = true; |
| 81 | setVisible(true); |
| 82 | }, |
| 83 | [bottom, contentRef, liquid, top], |
| 84 | ); |
| 85 |
| 86 | const clear = React.useCallback(() => { |
| 87 | visibleRef.current = false; |
| 88 | setVisible(false); |
| 89 | // Leaving and returning quickly keeps the glide, so a brief overshoot off |
| 90 | // the menu doesn't make the pill jump when the pointer comes back. |
| 91 | if (resetTimerRef.current !== null) { |
| 92 | window.clearTimeout(resetTimerRef.current); |
| 93 | } |
| 94 | resetTimerRef.current = window.setTimeout(() => { |
| 95 | nextAppearIsInstantRef.current = true; |
| 96 | resetTimerRef.current = null; |
| 97 | }, 50); |
| 98 | }, []); |
| 99 |
| 100 | React.useEffect( |
| 101 | () => () => { |
| 102 | if (resetTimerRef.current !== null) { |
| 103 | window.clearTimeout(resetTimerRef.current); |
| 104 | } |
| 105 | }, |
| 106 | [], |
| 107 | ); |
| 108 |
| 109 | // Per-frame hit-testing, scoped to this menu so it never fights another open |
| 110 | // surface. Torn down with the menu, which is why it is created on mount |
| 111 | // rather than kept alive globally. |
| 112 | React.useEffect(() => { |
| 113 | const content = contentRef.current; |
| 114 | if (!content) return undefined; |
| 115 |
| 116 | const handleEnter = (event: Event) => { |
| 117 | const detail = (event as CustomEvent<SuperHoverEventDetail>).detail; |
| 118 | if (detail.current instanceof HTMLElement) { |
| 119 | reportHover(detail.current); |
| 120 | } |
| 121 | }; |
| 122 |
| 123 | const handleLeave = (event: Event) => { |
| 124 | const detail = (event as CustomEvent<SuperHoverEventDetail>).detail; |
| 125 | // Only clear when the pointer left the menu entirely; moving between two |
| 126 | // rows fires leave-then-enter and would otherwise flicker the pill. |
| 127 | if (!detail.current) { |
| 128 | clear(); |
| 129 | } |
| 130 | }; |
| 131 |
| 132 | content.addEventListener("superhoverenter", handleEnter); |
| 133 | content.addEventListener("superhoverleave", handleLeave); |
| 134 |
| 135 | const controller = createSuperHover({ |
| 136 | root: content, |
| 137 | events: ["enter", "leave"], |
| 138 | }); |
| 139 |
| 140 | return () => { |
| 141 | content.removeEventListener("superhoverenter", handleEnter); |
| 142 | content.removeEventListener("superhoverleave", handleLeave); |
| 143 | controller.destroy(); |
| 144 | }; |
| 145 | }, [clear, contentRef, reportHover]); |
| 146 |
| 147 | const ctx = React.useMemo(() => ({ reportHover }), [reportHover]); |
| 148 |
| 149 | const element = ( |
| 150 | <motion.div |
| 151 | className="liquid-glass-well pointer-events-none absolute inset-x-1 top-0 z-0 rounded-2xl bg-black/[0.045] transform-gpu will-change-[transform,opacity] dark:bg-white/[0.08]" |
| 152 | style={{ y: top, height }} |
| 153 | initial={false} |
| 154 | animate={{ opacity: visible ? 1 : 0 }} |
| 155 | transition={{ |
| 156 | opacity: { |
| 157 | duration: instant ? 0.05 : 0.1, |
| 158 | ease: "linear", |
| 159 | }, |
| 160 | }} |
| 161 | /> |
| 162 | ); |
| 163 |
| 164 | return { ctx, clear, element }; |
| 165 | } |
| 166 |