Hover Card
For sighted users to preview content available behind a link.
package examples import (package examples import ( "github.com/axadrn/shadcn-templ/v2/components/button" "github.com/axadrn/shadcn-templ/v2/components/hovercard") templ HoverCardDemo() { @hovercard.HoverCard(hovercard.Props{ Delay: 10, CloseDelay: 100, }) { @button.Button(button.Props{ Variant: button.VariantLink, Attributes: hovercard.Trigger(ctx), }) { Hover Here } @hovercard.Content(hovercard.ContentProps{Class: "flex w-64 flex-col gap-0.5"}) { <div class="font-semibold">{ "@templ" }</div> <div>The HTML templating language for Go – created and maintained by { "@a-h" }.</div> <div class="mt-1 text-xs text-muted-foreground">Joined December 2021</div> } }}Installation
shadcn-templ add hover-cardCopy and paste the following code into your project.
package hovercard import ( "context" "strconv" "github.com/axadrn/shadcn-templ/v2/utils") type Side string const ( SideTop Side = "top" SideRight Side = "right" SideBottom Side = "bottom" SideLeft Side = "left") type Align string const ( AlignStart Align = "start" AlignCenter Align = "center" AlignEnd Align = "end") type ctxKey string const idKey ctxKey = "hovercardID" type delaysKey struct{} type delays struct { open int close int} func cardID(ctx context.Context) string { if id, ok := ctx.Value(idKey).(string); ok { return id } return ""} // Trigger returns the attributes that turn any element into the hover card// trigger — the asChild equivalent: no wrapper element.func Trigger(ctx context.Context) templ.Attributes { return templ.Attributes{ "data-tui-hovercard-trigger": true, "data-tui-hovercard-for": cardID(ctx), }} type Props struct { ID string // Delay is the hover intent before showing, in ms. Defaults to 600 like Base UI. Delay int // CloseDelay is the grace period before hiding, in ms. Defaults to 300. CloseDelay int} type ContentProps struct { Class string Attributes templ.Attributes // Side of the trigger the card opens on. Defaults to bottom; flips // automatically when there is no room. Side Side // Align along the trigger edge. Defaults to center. Align Align // SideOffset is the gap to the trigger in px. Defaults to 4. SideOffset int // AlignOffset shifts the card along the aligned edge in px. Defaults // to 4 (Base UI); only applies with Align start or end. AlignOffset int} // HoverCard renders no element: it only generates the id that links Trigger// and Content (via ctx), so the trigger stays a direct child of its layout.templ HoverCard(props ...Props) { {{ var p Props }} if len(props) > 0 { {{ p = props[0] }} } if p.ID == "" { {{ p.ID = utils.RandomID() }} } if p.Delay == 0 { {{ p.Delay = 600 }} } if p.CloseDelay == 0 { {{ p.CloseDelay = 300 }} } {{ ctx = context.WithValue(ctx, idKey, p.ID) }} {{ ctx = context.WithValue(ctx, delaysKey{}, delays{open: p.Delay, close: p.CloseDelay}) }} { children... }} templ Content(props ...ContentProps) { {{ var p ContentProps }} if len(props) > 0 { {{ p = props[0] }} } if p.Side == "" { {{ p.Side = SideBottom }} } if p.Align == "" { {{ p.Align = AlignCenter }} } if p.SideOffset == 0 { {{ p.SideOffset = 4 }} } if p.AlignOffset == 0 { {{ p.AlignOffset = 4 }} } {{ d, _ := ctx.Value(delaysKey{}).(delays) }} // Inert until the script portals it to <body> at init, so the SSRd // content never participates in layout or sibling CSS. <template data-tui-hovercard-portal> <div id={ cardID(ctx) } data-slot="hover-card-content" data-tui-hovercard-content data-tui-hovercard-side={ string(p.Side) } data-tui-hovercard-align={ string(p.Align) } data-tui-hovercard-side-offset={ strconv.Itoa(p.SideOffset) } data-tui-hovercard-align-offset={ strconv.Itoa(p.AlignOffset) } data-tui-hovercard-delay={ strconv.Itoa(d.open) } data-tui-hovercard-close-delay={ strconv.Itoa(d.close) } data-state="closed" popover="manual" class={ utils.CN( // 1:1 base/ui/hover-card.tsx HoverCardContent, the look comes from // cn-hover-card-content (animations key on our data-state attribute). // origin-(--transform-origin) becomes our JS variable name. "cn-hover-card-content cn-hover-card-content-logical z-50 origin-(--tui-hovercard-transform-origin) outline-hidden", // Native [popover] resets plus JS wiring: the popup stays mounted // after animate-out until hidePopover runs. "fixed inset-auto left-0 top-0 m-0 overflow-visible data-closed:fill-mode-forwards", p.Class, ), } { p.Attributes... } > { children... } </div> </template>}// Uses window.FloatingUIDOM from components/floatingui (loaded in the same bundle).(function () { // Exit animations run for 100ms (duration-100); hide shortly after. const EXIT_MS = 120; function allContents() { return document.querySelectorAll("[data-tui-hovercard-content]"); } function contentFor(trigger) { return document.getElementById(trigger.getAttribute("data-tui-hovercard-for")); } function triggerFor(content) { return document.querySelector( '[data-tui-hovercard-trigger][data-tui-hovercard-for="' + content.id + '"]', ); } // Base UI zooms the popup out of the anchor's center point (e.g. // "96px -4px"), not out of a placement corner. function anchorOrigin(result, anchorRect, sideOffset) { const side = result.placement.split("-")[0]; const centerX = anchorRect.left + anchorRect.width / 2 - result.x + "px"; const centerY = anchorRect.top + anchorRect.height / 2 - result.y + "px"; if (side === "bottom") return centerX + " " + -sideOffset + "px"; if (side === "top") return centerX + " calc(100% + " + sideOffset + "px)"; if (side === "right") return -sideOffset + "px " + centerY; return "calc(100% + " + sideOffset + "px) " + centerY; } // Moves the content to <body> (shadcn portals it the same way). Also removes // contents whose trigger is gone (leftovers from swapped-out pages). function portal(content) { document.querySelectorAll("body > [data-tui-hovercard-content]").forEach((c) => { if (c !== content && !triggerFor(c)) c.remove(); }); if (content.parentElement !== document.body) { document.body.appendChild(content); } } function positionContent(content, trigger) { const { computePosition, offset, flip, shift } = window.FloatingUIDOM; const side = content.getAttribute("data-tui-hovercard-side") || "bottom"; const align = content.getAttribute("data-tui-hovercard-align") || "center"; const sideOffset = parseInt(content.getAttribute("data-tui-hovercard-side-offset"), 10) || 4; const alignOffset = parseInt(content.getAttribute("data-tui-hovercard-align-offset"), 10) || 0; const placement = align === "center" ? side : side + "-" + align; return computePosition(trigger, content, { placement: placement, strategy: "fixed", middleware: [ offset({ mainAxis: sideOffset, alignmentAxis: alignOffset }), flip(), shift({ padding: 8 }), ], }).then((result) => { content.style.transition = "none"; content.style.left = result.x + "px"; content.style.top = result.y + "px"; content.style.setProperty( "--tui-hovercard-transform-origin", anchorOrigin(result, trigger.getBoundingClientRect(), sideOffset), ); content.setAttribute("data-side", result.placement.split("-")[0]); content.offsetHeight; // flush styles before re-enabling transitions content.style.transition = ""; }); } function open(content, trigger) { clearTimeout(content._tuiHide); portal(content); if (!content.matches(":popover-open")) { content.showPopover(); // native top layer } // Position it invisibly first, then play the enter animation in place. content.style.visibility = "hidden"; positionContent(content, trigger).then(() => { if (!content.matches(":popover-open")) return; // closed meanwhile content.style.visibility = ""; content.setAttribute("data-state", "open"); }); } function close(content) { if (!content.matches(":popover-open")) return; content.setAttribute("data-state", "closed"); clearTimeout(content._tuiHide); content._tuiHide = setTimeout(() => { if (content.getAttribute("data-state") === "closed" && content.matches(":popover-open")) { content.hidePopover(); } }, EXIT_MS); } // Hover intent: entering trigger or card keeps it open; leaving both // schedules the close after the card's close delay. function scheduleOpen(content, trigger) { clearTimeout(content._tuiClose); content._tuiClose = null; if (content.getAttribute("data-state") === "open" || content._tuiOpen) return; const delay = parseInt(content.getAttribute("data-tui-hovercard-delay"), 10) || 600; content._tuiOpen = setTimeout(() => { content._tuiOpen = null; open(content, trigger); }, delay); } function scheduleClose(content) { clearTimeout(content._tuiOpen); content._tuiOpen = null; if (content.getAttribute("data-state") !== "open" || content._tuiClose) return; const delay = parseInt(content.getAttribute("data-tui-hovercard-close-delay"), 10) || 300; content._tuiClose = setTimeout(() => { content._tuiClose = null; close(content); }, delay); } document.addEventListener("mouseover", (e) => { const trigger = e.target.closest("[data-tui-hovercard-trigger]"); if (trigger) { const content = contentFor(trigger); if (content) scheduleOpen(content, trigger); return; } const content = e.target.closest("[data-tui-hovercard-content]"); if (content) { clearTimeout(content._tuiClose); content._tuiClose = null; } }); document.addEventListener("mouseout", (e) => { const from = e.target.closest("[data-tui-hovercard-trigger], [data-tui-hovercard-content]"); if (!from) return; const content = from.hasAttribute("data-tui-hovercard-content") ? from : contentFor(from); if (!content) return; if (e.relatedTarget) { const to = e.relatedTarget.closest("[data-tui-hovercard-trigger], [data-tui-hovercard-content]"); // Only moving between THIS card's trigger and popup keeps it open; // landing on another instance must still close this one. if (to) { const toContent = to.hasAttribute("data-tui-hovercard-content") ? to : contentFor(to); if (toContent === content) return; } } scheduleClose(content); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") allContents().forEach(close); }); // Lift SSR'd contents out of their inert <template> wrappers into <body>, // replacing a stale portaled copy on re-swaps (e.g. htmx). function liftTemplates() { document.querySelectorAll("template[data-tui-hovercard-portal]").forEach((tpl) => { const content = tpl.content.querySelector("[data-tui-hovercard-content]"); if (content) { const stale = document.getElementById(content.id); if (stale) stale.remove(); document.body.appendChild(content); } tpl.remove(); }); } // Portal all contents up front (React portals on mount too). Runs on load // and whenever new cards appear in the DOM. function initCards() { liftTemplates(); allContents().forEach((content) => { if (triggerFor(content)) portal(content); }); } let initQueued = false; function queueInit() { if (initQueued) return; initQueued = true; requestAnimationFrame(() => { initQueued = false; initCards(); }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initCards); } else { initCards(); } new MutationObserver((mutations) => { for (const m of mutations) { if (m.addedNodes.length) { queueInit(); break; } } }).observe(document.documentElement, { childList: true, subtree: true }); // Keep open cards anchored while scrolling or resizing. function repositionOpen() { allContents().forEach((content) => { if (content.getAttribute("data-state") !== "open") return; const trigger = triggerFor(content); if (trigger) positionContent(content, trigger); }); } window.addEventListener("scroll", repositionOpen, true); window.addEventListener("resize", repositionOpen);})();// https://cdn.jsdelivr.net/npm/@floating-ui/[email protected]!(function (t, e) { "object" == typeof exports && "undefined" != typeof module ? e(exports) : "function" == typeof define && define.amd ? define(["exports"], e) : e( ((t = "undefined" != typeof globalThis ? globalThis : t || self).FloatingUICore = {}) );})(this, function (t) { "use strict"; const e = ["top", "right", "bottom", "left"], n = ["start", "end"], i = e.reduce((t, e) => t.concat(e, e + "-" + n[0], e + "-" + n[1]), []), o = Math.min, r = Math.max, a = { left: "right", right: "left", bottom: "top", top: "bottom" }, l = { start: "end", end: "start" }; function s(t, e, n) { return r(t, o(e, n)); } function f(t, e) { return "function" == typeof t ? t(e) : t; } function c(t) { return t.split("-")[0]; } function u(t) { return t.split("-")[1]; } function m(t) { return "x" === t ? "y" : "x"; } function d(t) { return "y" === t ? "height" : "width"; } function g(t) { return ["top", "bottom"].includes(c(t)) ? "y" : "x"; } function p(t) { return m(g(t)); } function h(t, e, n) { void 0 === n && (n = !1); const i = u(t), o = p(t), r = d(o); let a = "x" === o ? i === (n ? "end" : "start") ? "right" : "left" : "start" === i ? "bottom" : "top"; return e.reference[r] > e.floating[r] && (a = w(a)), [a, w(a)]; } function y(t) { return t.replace(/start|end/g, (t) => l[t]); } function w(t) { return t.replace(/left|right|bottom|top/g, (t) => a[t]); } function x(t) { return "number" != typeof t ? (function (t) { return { top: 0, right: 0, bottom: 0, left: 0, ...t }; })(t) : { top: t, right: t, bottom: t, left: t }; } function v(t) { const { x: e, y: n, width: i, height: o } = t; return { width: i, height: o, top: n, left: e, right: e + i, bottom: n + o, x: e, y: n, }; } function b(t, e, n) { let { reference: i, floating: o } = t; const r = g(e), a = p(e), l = d(a), s = c(e), f = "y" === r, m = i.x + i.width / 2 - o.width / 2, h = i.y + i.height / 2 - o.height / 2, y = i[l] / 2 - o[l] / 2; let w; switch (s) { case "top": w = { x: m, y: i.y - o.height }; break; case "bottom": w = { x: m, y: i.y + i.height }; break; case "right": w = { x: i.x + i.width, y: h }; break; case "left": w = { x: i.x - o.width, y: h }; break; default: w = { x: i.x, y: i.y }; } switch (u(e)) { case "start": w[a] -= y * (n && f ? -1 : 1); break; case "end": w[a] += y * (n && f ? -1 : 1); } return w; } async function A(t, e) { var n; void 0 === e && (e = {}); const { x: i, y: o, platform: r, rects: a, elements: l, strategy: s } = t, { boundary: c = "clippingAncestors", rootBoundary: u = "viewport", elementContext: m = "floating", altBoundary: d = !1, padding: g = 0, } = f(e, t), p = x(g), h = l[d ? ("floating" === m ? "reference" : "floating") : m], y = v( await r.getClippingRect({ element: null == (n = await (null == r.isElement ? void 0 : r.isElement(h))) || n ? h : h.contextElement || (await (null == r.getDocumentElement ? void 0 : r.getDocumentElement(l.floating))), boundary: c, rootBoundary: u, strategy: s, }) ), w = "floating" === m ? { x: i, y: o, width: a.floating.width, height: a.floating.height } : a.reference, b = await (null == r.getOffsetParent ? void 0 : r.getOffsetParent(l.floating)), A = ((await (null == r.isElement ? void 0 : r.isElement(b))) && (await (null == r.getScale ? void 0 : r.getScale(b)))) || { x: 1, y: 1, }, R = v( r.convertOffsetParentRelativeRectToViewportRelativeRect ? await r.convertOffsetParentRelativeRectToViewportRelativeRect({ elements: l, rect: w, offsetParent: b, strategy: s, }) : w ); return { top: (y.top - R.top + p.top) / A.y, bottom: (R.bottom - y.bottom + p.bottom) / A.y, left: (y.left - R.left + p.left) / A.x, right: (R.right - y.right + p.right) / A.x, }; } function R(t, e) { return { top: t.top - e.height, right: t.right - e.width, bottom: t.bottom - e.height, left: t.left - e.width, }; } function P(t) { return e.some((e) => t[e] >= 0); } function D(t) { const e = o(...t.map((t) => t.left)), n = o(...t.map((t) => t.top)); return { x: e, y: n, width: r(...t.map((t) => t.right)) - e, height: r(...t.map((t) => t.bottom)) - n, }; } (t.arrow = (t) => ({ name: "arrow", options: t, async fn(e) { const { x: n, y: i, placement: r, rects: a, platform: l, elements: c, middlewareData: m, } = e, { element: g, padding: h = 0 } = f(t, e) || {}; if (null == g) return {}; const y = x(h), w = { x: n, y: i }, v = p(r), b = d(v), A = await l.getDimensions(g), R = "y" === v, P = R ? "top" : "left", D = R ? "bottom" : "right", T = R ? "clientHeight" : "clientWidth", O = a.reference[b] + a.reference[v] - w[v] - a.floating[b], E = w[v] - a.reference[v], L = await (null == l.getOffsetParent ? void 0 : l.getOffsetParent(g)); let k = L ? L[T] : 0; (k && (await (null == l.isElement ? void 0 : l.isElement(L)))) || (k = c.floating[T] || a.floating[b]); const C = O / 2 - E / 2, B = k / 2 - A[b] / 2 - 1, H = o(y[P], B), S = o(y[D], B), F = H, j = k - A[b] - S, z = k / 2 - A[b] / 2 + C, M = s(F, z, j), V = !m.arrow && null != u(r) && z !== M && a.reference[b] / 2 - (z < F ? H : S) - A[b] / 2 < 0, W = V ? (z < F ? z - F : z - j) : 0; return { [v]: w[v] + W, data: { [v]: M, centerOffset: z - M - W, ...(V && { alignmentOffset: W }), }, reset: V, }; }, })), (t.autoPlacement = function (t) { return ( void 0 === t && (t = {}), { name: "autoPlacement", options: t, async fn(e) { var n, o, r; const { rects: a, middlewareData: l, placement: s, platform: m, elements: d, } = e, { crossAxis: g = !1, alignment: p, allowedPlacements: w = i, autoAlignment: x = !0, ...v } = f(t, e), b = void 0 !== p || w === i ? (function (t, e, n) { return ( t ? [ ...n.filter((e) => u(e) === t), ...n.filter((e) => u(e) !== t), ] : n.filter((t) => c(t) === t) ).filter((n) => !t || u(n) === t || (!!e && y(n) !== n)); })(p || null, x, w) : w, R = await A(e, v), P = (null == (n = l.autoPlacement) ? void 0 : n.index) || 0, D = b[P]; if (null == D) return {}; const T = h( D, a, await (null == m.isRTL ? void 0 : m.isRTL(d.floating)) ); if (s !== D) return { reset: { placement: b[0] } }; const O = [R[c(D)], R[T[0]], R[T[1]]], E = [ ...((null == (o = l.autoPlacement) ? void 0 : o.overflows) || []), { placement: D, overflows: O }, ], L = b[P + 1]; if (L) return { data: { index: P + 1, overflows: E }, reset: { placement: L }, }; const k = E.map((t) => { const e = u(t.placement); return [ t.placement, e && g ? t.overflows.slice(0, 2).reduce((t, e) => t + e, 0) : t.overflows[0], t.overflows, ]; }).sort((t, e) => t[1] - e[1]), C = (null == (r = k.filter((t) => t[2].slice(0, u(t[0]) ? 2 : 3).every((t) => t <= 0) )[0]) ? void 0 : r[0]) || k[0][0]; return C !== s ? { data: { index: P + 1, overflows: E }, reset: { placement: C }, } : {}; }, } ); }), (t.computePosition = async (t, e, n) => { const { placement: i = "bottom", strategy: o = "absolute", middleware: r = [], platform: a, } = n, l = r.filter(Boolean), s = await (null == a.isRTL ? void 0 : a.isRTL(e)); let f = await a.getElementRects({ reference: t, floating: e, strategy: o, }), { x: c, y: u } = b(f, i, s), m = i, d = {}, g = 0; for (let n = 0; n < l.length; n++) { const { name: r, fn: p } = l[n], { x: h, y: y, data: w, reset: x, } = await p({ x: c, y: u, initialPlacement: i, placement: m, strategy: o, middlewareData: d, rects: f, platform: a, elements: { reference: t, floating: e }, }); (c = null != h ? h : c), (u = null != y ? y : u), (d = { ...d, [r]: { ...d[r], ...w } }), x && g <= 50 && (g++, "object" == typeof x && (x.placement && (m = x.placement), x.rects && (f = !0 === x.rects ? await a.getElementRects({ reference: t, floating: e, strategy: o, }) : x.rects), ({ x: c, y: u } = b(f, m, s))), (n = -1)); } return { x: c, y: u, placement: m, strategy: o, middlewareData: d }; }), (t.detectOverflow = A), (t.flip = function (t) { return ( void 0 === t && (t = {}), { name: "flip", options: t, async fn(e) { var n, i; const { placement: o, middlewareData: r, rects: a, initialPlacement: l, platform: s, elements: m, } = e, { mainAxis: d = !0, crossAxis: p = !0, fallbackPlacements: x, fallbackStrategy: v = "bestFit", fallbackAxisSideDirection: b = "none", flipAlignment: R = !0, ...P } = f(t, e); if (null != (n = r.arrow) && n.alignmentOffset) return {}; const D = c(o), T = g(l), O = c(l) === l, E = await (null == s.isRTL ? void 0 : s.isRTL(m.floating)), L = x || (O || !R ? [w(l)] : (function (t) { const e = w(t); return [y(t), e, y(e)]; })(l)), k = "none" !== b; !x && k && L.push( ...(function (t, e, n, i) { const o = u(t); let r = (function (t, e, n) { const i = ["left", "right"], o = ["right", "left"], r = ["top", "bottom"], a = ["bottom", "top"]; switch (t) { case "top": case "bottom": return n ? (e ? o : i) : e ? i : o; case "left": case "right": return e ? r : a; default: return []; } })(c(t), "start" === n, i); return ( o && ((r = r.map((t) => t + "-" + o)), e && (r = r.concat(r.map(y)))), r ); })(l, R, b, E) ); const C = [l, ...L], B = await A(e, P), H = []; let S = (null == (i = r.flip) ? void 0 : i.overflows) || []; if ((d && H.push(B[D]), p)) { const t = h(o, a, E); H.push(B[t[0]], B[t[1]]); } if ( ((S = [...S, { placement: o, overflows: H }]), !H.every((t) => t <= 0)) ) { var F, j; const t = ((null == (F = r.flip) ? void 0 : F.index) || 0) + 1, e = C[t]; if (e) { var z; const n = "alignment" === p && T !== g(e), i = (null == (z = S[0]) ? void 0 : z.overflows[0]) > 0; if (!n || i) return { data: { index: t, overflows: S }, reset: { placement: e }, }; } let n = null == (j = S.filter((t) => t.overflows[0] <= 0).sort( (t, e) => t.overflows[1] - e.overflows[1] )[0]) ? void 0 : j.placement; if (!n) switch (v) { case "bestFit": { var M; const t = null == (M = S.filter((t) => { if (k) { const e = g(t.placement); return e === T || "y" === e; } return !0; }) .map((t) => [ t.placement, t.overflows .filter((t) => t > 0) .reduce((t, e) => t + e, 0), ]) .sort((t, e) => t[1] - e[1])[0]) ? void 0 : M[0]; t && (n = t); break; } case "initialPlacement": n = l; } if (o !== n) return { reset: { placement: n } }; } return {}; }, } ); }), (t.hide = function (t) { return ( void 0 === t && (t = {}), { name: "hide", options: t, async fn(e) { const { rects: n } = e, { strategy: i = "referenceHidden", ...o } = f(t, e); switch (i) { case "referenceHidden": { const t = R( await A(e, { ...o, elementContext: "reference" }), n.reference ); return { data: { referenceHiddenOffsets: t, referenceHidden: P(t) }, }; } case "escaped": { const t = R(await A(e, { ...o, altBoundary: !0 }), n.floating); return { data: { escapedOffsets: t, escaped: P(t) } }; } default: return {}; } }, } ); }), (t.inline = function (t) { return ( void 0 === t && (t = {}), { name: "inline", options: t, async fn(e) { const { placement: n, elements: i, rects: a, platform: l, strategy: s, } = e, { padding: u = 2, x: m, y: d } = f(t, e), p = Array.from( (await (null == l.getClientRects ? void 0 : l.getClientRects(i.reference))) || [] ), h = (function (t) { const e = t.slice().sort((t, e) => t.y - e.y), n = []; let i = null; for (let t = 0; t < e.length; t++) { const o = e[t]; !i || o.y - i.y > i.height / 2 ? n.push([o]) : n[n.length - 1].push(o), (i = o); } return n.map((t) => v(D(t))); })(p), y = v(D(p)), w = x(u); const b = await l.getElementRects({ reference: { getBoundingClientRect: function () { if ( 2 === h.length && h[0].left > h[1].right && null != m && null != d ) return ( h.find( (t) => m > t.left - w.left && m < t.right + w.right && d > t.top - w.top && d < t.bottom + w.bottom ) || y ); if (h.length >= 2) { if ("y" === g(n)) { const t = h[0], e = h[h.length - 1], i = "top" === c(n), o = t.top, r = e.bottom, a = i ? t.left : e.left, l = i ? t.right : e.right; return { top: o, bottom: r, left: a, right: l, width: l - a, height: r - o, x: a, y: o, }; } const t = "left" === c(n), e = r(...h.map((t) => t.right)), i = o(...h.map((t) => t.left)), a = h.filter((n) => (t ? n.left === i : n.right === e)), l = a[0].top, s = a[a.length - 1].bottom; return { top: l, bottom: s, left: i, right: e, width: e - i, height: s - l, x: i, y: l, }; } return y; }, }, floating: i.floating, strategy: s, }); return a.reference.x !== b.reference.x || a.reference.y !== b.reference.y || a.reference.width !== b.reference.width || a.reference.height !== b.reference.height ? { reset: { rects: b } } : {}; }, } ); }), (t.limitShift = function (t) { return ( void 0 === t && (t = {}), { options: t, fn(e) { const { x: n, y: i, placement: o, rects: r, middlewareData: a } = e, { offset: l = 0, mainAxis: s = !0, crossAxis: u = !0 } = f(t, e), d = { x: n, y: i }, p = g(o), h = m(p); let y = d[h], w = d[p]; const x = f(l, e), v = "number" == typeof x ? { mainAxis: x, crossAxis: 0 } : { mainAxis: 0, crossAxis: 0, ...x }; if (s) { const t = "y" === h ? "height" : "width", e = r.reference[h] - r.floating[t] + v.mainAxis, n = r.reference[h] + r.reference[t] - v.mainAxis; y < e ? (y = e) : y > n && (y = n); } if (u) { var b, A; const t = "y" === h ? "width" : "height", e = ["top", "left"].includes(c(o)), n = r.reference[p] - r.floating[t] + ((e && (null == (b = a.offset) ? void 0 : b[p])) || 0) + (e ? 0 : v.crossAxis), i = r.reference[p] + r.reference[t] + (e ? 0 : (null == (A = a.offset) ? void 0 : A[p]) || 0) - (e ? v.crossAxis : 0); w < n ? (w = n) : w > i && (w = i); } return { [h]: y, [p]: w }; }, } ); }), (t.offset = function (t) { return ( void 0 === t && (t = 0), { name: "offset", options: t, async fn(e) { var n, i; const { x: o, y: r, placement: a, middlewareData: l } = e, s = await (async function (t, e) { const { placement: n, platform: i, elements: o } = t, r = await (null == i.isRTL ? void 0 : i.isRTL(o.floating)), a = c(n), l = u(n), s = "y" === g(n), m = ["left", "top"].includes(a) ? -1 : 1, d = r && s ? -1 : 1, p = f(e, t); let { mainAxis: h, crossAxis: y, alignmentAxis: w, } = "number" == typeof p ? { mainAxis: p, crossAxis: 0, alignmentAxis: null } : { mainAxis: p.mainAxis || 0, crossAxis: p.crossAxis || 0, alignmentAxis: p.alignmentAxis, }; return ( l && "number" == typeof w && (y = "end" === l ? -1 * w : w), s ? { x: y * d, y: h * m } : { x: h * m, y: y * d } ); })(e, t); return a === (null == (n = l.offset) ? void 0 : n.placement) && null != (i = l.arrow) && i.alignmentOffset ? {} : { x: o + s.x, y: r + s.y, data: { ...s, placement: a } }; }, } ); }), (t.rectToClientRect = v), (t.shift = function (t) { return ( void 0 === t && (t = {}), { name: "shift", options: t, async fn(e) { const { x: n, y: i, placement: o } = e, { mainAxis: r = !0, crossAxis: a = !1, limiter: l = { fn: (t) => { let { x: e, y: n } = t; return { x: e, y: n }; }, }, ...u } = f(t, e), d = { x: n, y: i }, p = await A(e, u), h = g(c(o)), y = m(h); let w = d[y], x = d[h]; if (r) { const t = "y" === y ? "bottom" : "right"; w = s(w + p["y" === y ? "top" : "left"], w, w - p[t]); } if (a) { const t = "y" === h ? "bottom" : "right"; x = s(x + p["y" === h ? "top" : "left"], x, x - p[t]); } const v = l.fn({ ...e, [y]: w, [h]: x }); return { ...v, data: { x: v.x - n, y: v.y - i, enabled: { [y]: r, [h]: a } }, }; }, } ); }), (t.size = function (t) { return ( void 0 === t && (t = {}), { name: "size", options: t, async fn(e) { var n, i; const { placement: a, rects: l, platform: s, elements: m } = e, { apply: d = () => {}, ...p } = f(t, e), h = await A(e, p), y = c(a), w = u(a), x = "y" === g(a), { width: v, height: b } = l.floating; let R, P; "top" === y || "bottom" === y ? ((R = y), (P = w === ((await (null == s.isRTL ? void 0 : s.isRTL(m.floating))) ? "start" : "end") ? "left" : "right")) : ((P = y), (R = "end" === w ? "top" : "bottom")); const D = b - h.top - h.bottom, T = v - h.left - h.right, O = o(b - h[R], D), E = o(v - h[P], T), L = !e.middlewareData.shift; let k = O, C = E; if ( (null != (n = e.middlewareData.shift) && n.enabled.x && (C = T), null != (i = e.middlewareData.shift) && i.enabled.y && (k = D), L && !w) ) { const t = r(h.left, 0), e = r(h.right, 0), n = r(h.top, 0), i = r(h.bottom, 0); x ? (C = v - 2 * (0 !== t || 0 !== e ? t + e : r(h.left, h.right))) : (k = b - 2 * (0 !== n || 0 !== i ? n + i : r(h.top, h.bottom))); } await d({ ...e, availableWidth: C, availableHeight: k }); const B = await s.getDimensions(m.floating); return v !== B.width || b !== B.height ? { reset: { rects: !0 } } : {}; }, } ); });});// https://cdn.jsdelivr.net/npm/@floating-ui/[email protected]!(function (t, e) { "object" == typeof exports && "undefined" != typeof module ? e(exports, require("./floating_ui_core")) : "function" == typeof define && define.amd ? define(["exports", "./floatingUICore"], e) : e( ((t = "undefined" != typeof globalThis ? globalThis : t || self).FloatingUIDOM = {}), t.FloatingUICore );})(this, function (t, e) { "use strict"; const n = Math.min, o = Math.max, i = Math.round, r = Math.floor, c = (t) => ({ x: t, y: t }); function l() { return "undefined" != typeof window; } function s(t) { return a(t) ? (t.nodeName || "").toLowerCase() : "#document"; } function f(t) { var e; return ( (null == t || null == (e = t.ownerDocument) ? void 0 : e.defaultView) || window ); } function u(t) { var e; return null == (e = (a(t) ? t.ownerDocument : t.document) || window.document) ? void 0 : e.documentElement; } function a(t) { return !!l() && (t instanceof Node || t instanceof f(t).Node); } function d(t) { return !!l() && (t instanceof Element || t instanceof f(t).Element); } function h(t) { return !!l() && (t instanceof HTMLElement || t instanceof f(t).HTMLElement); } function p(t) { return ( !(!l() || "undefined" == typeof ShadowRoot) && (t instanceof ShadowRoot || t instanceof f(t).ShadowRoot) ); } function g(t) { const { overflow: e, overflowX: n, overflowY: o, display: i } = b(t); return ( /auto|scroll|overlay|hidden|clip/.test(e + o + n) && !["inline", "contents"].includes(i) ); } function m(t) { return ["table", "td", "th"].includes(s(t)); } function y(t) { return [":popover-open", ":modal"].some((e) => { try { return t.matches(e); } catch (t) { return !1; } }); } function w(t) { const e = x(), n = d(t) ? b(t) : t; return ( ["transform", "translate", "scale", "rotate", "perspective"].some( (t) => !!n[t] && "none" !== n[t] ) || (!!n.containerType && "normal" !== n.containerType) || (!e && !!n.backdropFilter && "none" !== n.backdropFilter) || (!e && !!n.filter && "none" !== n.filter) || [ "transform", "translate", "scale", "rotate", "perspective", "filter", ].some((t) => (n.willChange || "").includes(t)) || ["paint", "layout", "strict", "content"].some((t) => (n.contain || "").includes(t) ) ); } function x() { return ( !("undefined" == typeof CSS || !CSS.supports) && CSS.supports("-webkit-backdrop-filter", "none") ); } function v(t) { return ["html", "body", "#document"].includes(s(t)); } function b(t) { return f(t).getComputedStyle(t); } function T(t) { return d(t) ? { scrollLeft: t.scrollLeft, scrollTop: t.scrollTop } : { scrollLeft: t.scrollX, scrollTop: t.scrollY }; } function L(t) { if ("html" === s(t)) return t; const e = t.assignedSlot || t.parentNode || (p(t) && t.host) || u(t); return p(e) ? e.host : e; } function R(t) { const e = L(t); return v(e) ? t.ownerDocument ? t.ownerDocument.body : t.body : h(e) && g(e) ? e : R(e); } function C(t, e, n) { var o; void 0 === e && (e = []), void 0 === n && (n = !0); const i = R(t), r = i === (null == (o = t.ownerDocument) ? void 0 : o.body), c = f(i); if (r) { const t = E(c); return e.concat( c, c.visualViewport || [], g(i) ? i : [], t && n ? C(t) : [] ); } return e.concat(i, C(i, [], n)); } function E(t) { return t.parent && Object.getPrototypeOf(t.parent) ? t.frameElement : null; } function S(t) { const e = b(t); let n = parseFloat(e.width) || 0, o = parseFloat(e.height) || 0; const r = h(t), c = r ? t.offsetWidth : n, l = r ? t.offsetHeight : o, s = i(n) !== c || i(o) !== l; return s && ((n = c), (o = l)), { width: n, height: o, $: s }; } function F(t) { return d(t) ? t : t.contextElement; } function O(t) { const e = F(t); if (!h(e)) return c(1); const n = e.getBoundingClientRect(), { width: o, height: r, $: l } = S(e); let s = (l ? i(n.width) : n.width) / o, f = (l ? i(n.height) : n.height) / r; return ( (s && Number.isFinite(s)) || (s = 1), (f && Number.isFinite(f)) || (f = 1), { x: s, y: f } ); } const D = c(0); function H(t) { const e = f(t); return x() && e.visualViewport ? { x: e.visualViewport.offsetLeft, y: e.visualViewport.offsetTop } : D; } function P(t, n, o, i) { void 0 === n && (n = !1), void 0 === o && (o = !1); const r = t.getBoundingClientRect(), l = F(t); let s = c(1); n && (i ? d(i) && (s = O(i)) : (s = O(t))); const u = (function (t, e, n) { return void 0 === e && (e = !1), !(!n || (e && n !== f(t))) && e; })(l, o, i) ? H(l) : c(0); let a = (r.left + u.x) / s.x, h = (r.top + u.y) / s.y, p = r.width / s.x, g = r.height / s.y; if (l) { const t = f(l), e = i && d(i) ? f(i) : i; let n = t, o = E(n); for (; o && i && e !== n; ) { const t = O(o), e = o.getBoundingClientRect(), i = b(o), r = e.left + (o.clientLeft + parseFloat(i.paddingLeft)) * t.x, c = e.top + (o.clientTop + parseFloat(i.paddingTop)) * t.y; (a *= t.x), (h *= t.y), (p *= t.x), (g *= t.y), (a += r), (h += c), (n = f(o)), (o = E(n)); } } return e.rectToClientRect({ width: p, height: g, x: a, y: h }); } function W(t, e) { const n = T(t).scrollLeft; return e ? e.left + n : P(u(t)).left + n; } function M(t, e, n) { void 0 === n && (n = !1); const o = t.getBoundingClientRect(); return { x: o.left + e.scrollLeft - (n ? 0 : W(t, o)), y: o.top + e.scrollTop, }; } function z(t, n, i) { let r; if ("viewport" === n) r = (function (t, e) { const n = f(t), o = u(t), i = n.visualViewport; let r = o.clientWidth, c = o.clientHeight, l = 0, s = 0; if (i) { (r = i.width), (c = i.height); const t = x(); (!t || (t && "fixed" === e)) && ((l = i.offsetLeft), (s = i.offsetTop)); } return { width: r, height: c, x: l, y: s }; })(t, i); else if ("document" === n) r = (function (t) { const e = u(t), n = T(t), i = t.ownerDocument.body, r = o(e.scrollWidth, e.clientWidth, i.scrollWidth, i.clientWidth), c = o(e.scrollHeight, e.clientHeight, i.scrollHeight, i.clientHeight); let l = -n.scrollLeft + W(t); const s = -n.scrollTop; return ( "rtl" === b(i).direction && (l += o(e.clientWidth, i.clientWidth) - r), { width: r, height: c, x: l, y: s } ); })(u(t)); else if (d(n)) r = (function (t, e) { const n = P(t, !0, "fixed" === e), o = n.top + t.clientTop, i = n.left + t.clientLeft, r = h(t) ? O(t) : c(1); return { width: t.clientWidth * r.x, height: t.clientHeight * r.y, x: i * r.x, y: o * r.y, }; })(n, i); else { const e = H(t); r = { x: n.x - e.x, y: n.y - e.y, width: n.width, height: n.height }; } return e.rectToClientRect(r); } function A(t, e) { const n = L(t); return ( !(n === e || !d(n) || v(n)) && ("fixed" === b(n).position || A(n, e)) ); } function B(t, e, n) { const o = h(e), i = u(e), r = "fixed" === n, l = P(t, !0, r, e); let f = { scrollLeft: 0, scrollTop: 0 }; const a = c(0); function d() { a.x = W(i); } if (o || (!o && !r)) if ((("body" !== s(e) || g(i)) && (f = T(e)), o)) { const t = P(e, !0, r, e); (a.x = t.x + e.clientLeft), (a.y = t.y + e.clientTop); } else i && d(); r && !o && i && d(); const p = !i || o || r ? c(0) : M(i, f); return { x: l.left + f.scrollLeft - a.x - p.x, y: l.top + f.scrollTop - a.y - p.y, width: l.width, height: l.height, }; } function V(t) { return "static" === b(t).position; } function N(t, e) { if (!h(t) || "fixed" === b(t).position) return null; if (e) return e(t); let n = t.offsetParent; return u(t) === n && (n = n.ownerDocument.body), n; } function I(t, e) { const n = f(t); if (y(t)) return n; if (!h(t)) { let e = L(t); for (; e && !v(e); ) { if (d(e) && !V(e)) return e; e = L(e); } return n; } let o = N(t, e); for (; o && m(o) && V(o); ) o = N(o, e); return o && v(o) && V(o) && !w(o) ? n : o || (function (t) { let e = L(t); for (; h(e) && !v(e); ) { if (w(e)) return e; if (y(e)) return null; e = L(e); } return null; })(t) || n; } const k = { convertOffsetParentRelativeRectToViewportRelativeRect: function (t) { let { elements: e, rect: n, offsetParent: o, strategy: i } = t; const r = "fixed" === i, l = u(o), f = !!e && y(e.floating); if (o === l || (f && r)) return n; let a = { scrollLeft: 0, scrollTop: 0 }, d = c(1); const p = c(0), m = h(o); if ( (m || (!m && !r)) && (("body" !== s(o) || g(l)) && (a = T(o)), h(o)) ) { const t = P(o); (d = O(o)), (p.x = t.x + o.clientLeft), (p.y = t.y + o.clientTop); } const w = !l || m || r ? c(0) : M(l, a, !0); return { width: n.width * d.x, height: n.height * d.y, x: n.x * d.x - a.scrollLeft * d.x + p.x + w.x, y: n.y * d.y - a.scrollTop * d.y + p.y + w.y, }; }, getDocumentElement: u, getClippingRect: function (t) { let { element: e, boundary: i, rootBoundary: r, strategy: c } = t; const l = [ ...("clippingAncestors" === i ? y(e) ? [] : (function (t, e) { const n = e.get(t); if (n) return n; let o = C(t, [], !1).filter((t) => d(t) && "body" !== s(t)), i = null; const r = "fixed" === b(t).position; let c = r ? L(t) : t; for (; d(c) && !v(c); ) { const e = b(c), n = w(c); n || "fixed" !== e.position || (i = null), ( r ? !n && !i : (!n && "static" === e.position && i && ["absolute", "fixed"].includes(i.position)) || (g(c) && !n && A(t, c)) ) ? (o = o.filter((t) => t !== c)) : (i = e), (c = L(c)); } return e.set(t, o), o; })(e, this._c) : [].concat(i)), r, ], f = l[0], u = l.reduce((t, i) => { const r = z(e, i, c); return ( (t.top = o(r.top, t.top)), (t.right = n(r.right, t.right)), (t.bottom = n(r.bottom, t.bottom)), (t.left = o(r.left, t.left)), t ); }, z(e, f, c)); return { width: u.right - u.left, height: u.bottom - u.top, x: u.left, y: u.top, }; }, getOffsetParent: I, getElementRects: async function (t) { const e = this.getOffsetParent || I, n = this.getDimensions, o = await n(t.floating); return { reference: B(t.reference, await e(t.floating), t.strategy), floating: { x: 0, y: 0, width: o.width, height: o.height }, }; }, getClientRects: function (t) { return Array.from(t.getClientRects()); }, getDimensions: function (t) { const { width: e, height: n } = S(t); return { width: e, height: n }; }, getScale: O, isElement: d, isRTL: function (t) { return "rtl" === b(t).direction; }, }; function q(t, e) { return ( t.x === e.x && t.y === e.y && t.width === e.width && t.height === e.height ); } const U = e.detectOverflow, j = e.offset, X = e.autoPlacement, Y = e.shift, $ = e.flip, _ = e.size, G = e.hide, J = e.arrow, K = e.inline, Q = e.limitShift; (t.arrow = J), (t.autoPlacement = X), (t.autoUpdate = function (t, e, i, c) { void 0 === c && (c = {}); const { ancestorScroll: l = !0, ancestorResize: s = !0, elementResize: f = "function" == typeof ResizeObserver, layoutShift: a = "function" == typeof IntersectionObserver, animationFrame: d = !1, } = c, h = F(t), p = l || s ? [...(h ? C(h) : []), ...C(e)] : []; p.forEach((t) => { l && t.addEventListener("scroll", i, { passive: !0 }), s && t.addEventListener("resize", i); }); const g = h && a ? (function (t, e) { let i, c = null; const l = u(t); function s() { var t; clearTimeout(i), null == (t = c) || t.disconnect(), (c = null); } return ( (function f(u, a) { void 0 === u && (u = !1), void 0 === a && (a = 1), s(); const d = t.getBoundingClientRect(), { left: h, top: p, width: g, height: m } = d; if ((u || e(), !g || !m)) return; const y = { rootMargin: -r(p) + "px " + -r(l.clientWidth - (h + g)) + "px " + -r(l.clientHeight - (p + m)) + "px " + -r(h) + "px", threshold: o(0, n(1, a)) || 1, }; let w = !0; function x(e) { const n = e[0].intersectionRatio; if (n !== a) { if (!w) return f(); n ? f(!1, n) : (i = setTimeout(() => { f(!1, 1e-7); }, 1e3)); } 1 !== n || q(d, t.getBoundingClientRect()) || f(), (w = !1); } try { c = new IntersectionObserver(x, { ...y, root: l.ownerDocument, }); } catch (t) { c = new IntersectionObserver(x, y); } c.observe(t); })(!0), s ); })(h, i) : null; let m, y = -1, w = null; f && ((w = new ResizeObserver((t) => { let [n] = t; n && n.target === h && w && (w.unobserve(e), cancelAnimationFrame(y), (y = requestAnimationFrame(() => { var t; null == (t = w) || t.observe(e); }))), i(); })), h && !d && w.observe(h), w.observe(e)); let x = d ? P(t) : null; return ( d && (function e() { const n = P(t); x && !q(x, n) && i(); (x = n), (m = requestAnimationFrame(e)); })(), i(), () => { var t; p.forEach((t) => { l && t.removeEventListener("scroll", i), s && t.removeEventListener("resize", i); }), null == g || g(), null == (t = w) || t.disconnect(), (w = null), d && cancelAnimationFrame(m); } ); }), (t.computePosition = (t, n, o) => { const i = new Map(), r = { platform: k, ...o }, c = { ...r.platform, _c: i }; return e.computePosition(t, n, { ...r, platform: c }); }), (t.detectOverflow = U), (t.flip = $), (t.getOverflowAncestors = C), (t.hide = G), (t.inline = K), (t.limitShift = Q), (t.offset = j), (t.platform = k), (t.shift = Y), (t.size = _); // We put this manually here because we need to make sure it's available // before the popover component is initialized. window.FloatingUIDOM = t; return t;});Component scripts are loaded through the shared script bundle, see JavaScript.
Update the import paths to match your project setup.
Usage
import "github.com/axadrn/shadcn-templ/v2/components/hovercard"@hovercard.HoverCard() { <span { hovercard.Trigger(ctx)... }>Hover</span> @hovercard.Content() { The Go templating language, created and maintained by @a-h. }}Composition
Use the following composition to build a HoverCard:
hovercard.HoverCard├── hovercard.Trigger└── hovercard.ContentTrigger Delays
Use Delay and CloseDelay on the root to control when the card opens and closes.
@hovercard.HoverCard(hovercard.Props{Delay: 100, CloseDelay: 200}) { <span { hovercard.Trigger(ctx)... }>Hover</span> @hovercard.Content() { Content }}Positioning
Use the Side and Align props on hovercard.Content to control placement.
@hovercard.HoverCard() { <span { hovercard.Trigger(ctx)... }>Hover</span> @hovercard.Content(hovercard.ContentProps{Side: hovercard.SideTop, Align: hovercard.AlignStart}) { Content }}Basic
package examples import (package examples import ( "github.com/axadrn/shadcn-templ/v2/components/button" "github.com/axadrn/shadcn-templ/v2/components/hovercard") templ HoverCardDemo() { @hovercard.HoverCard(hovercard.Props{ Delay: 10, CloseDelay: 100, }) { @button.Button(button.Props{ Variant: button.VariantLink, Attributes: hovercard.Trigger(ctx), }) { Hover Here } @hovercard.Content(hovercard.ContentProps{Class: "flex w-64 flex-col gap-0.5"}) { <div class="font-semibold">{ "@templ" }</div> <div>The HTML templating language for Go – created and maintained by { "@a-h" }.</div> <div class="mt-1 text-xs text-muted-foreground">Joined December 2021</div> } }}Sides
Hover Card
This hover card appears on the Left side of the trigger.
Hover Card
This hover card appears on the Top side of the trigger.
Hover Card
This hover card appears on the Bottom side of the trigger.
Hover Card
This hover card appears on the Right side of the trigger.
package examples import (package examples import ( "github.com/axadrn/shadcn-templ/v2/components/button" "github.com/axadrn/shadcn-templ/v2/components/hovercard") templ HoverCardSides() { <div class="flex flex-wrap justify-center gap-2"> @hoverCardSide("Left", hovercard.SideLeft) @hoverCardSide("Top", hovercard.SideTop) @hoverCardSide("Bottom", hovercard.SideBottom) @hoverCardSide("Right", hovercard.SideRight) </div>} templ hoverCardSide(label string, side hovercard.Side) { @hovercard.HoverCard(hovercard.Props{ Delay: 100, CloseDelay: 100, }) { @button.Button(button.Props{ Variant: button.VariantOutline, Class: "capitalize", Attributes: hovercard.Trigger(ctx), }) { { label } } @hovercard.Content(hovercard.ContentProps{Side: side}) { <div class="flex flex-col gap-1"> <h4 class="font-medium">Hover Card</h4> <p>This hover card appears on the { label } side of the trigger.</p> </div> } }}API Reference
HoverCard
The HoverCard component is the root that links trigger and content and controls the hover delays.
| Prop | Type | Default |
|---|---|---|
Delay |
int |
600 |
CloseDelay |
int |
300 |
Trigger
hovercard.Trigger(ctx) returns the attributes that turn any element into the trigger.
| Prop | Type | Default |
|---|---|---|
ctx |
context.Context |
- |
Content
The hovercard.Content component is the card, anchored to the trigger.
| Prop | Type | Default |
|---|---|---|
Side |
SideTop | SideRight | SideBottom | SideLeft |
SideBottom |
Align |
AlignStart | AlignCenter | AlignEnd |
AlignCenter |
SideOffset |
int |
4 |
AlignOffset |
int |
4 |
Class |
string |
- |