Popover
A click-triggered floating panel that scales from its anchor with a spring.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/components-popover.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ui/motion/Popover.tsx
import { useCallback, useRef, useState, type ReactNode } from 'react';
import { Check, Link, UserPlus } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useClickOutside, useEscape, usePresence } from './hooks';
type Align = 'start' | 'center' | 'end';
const ALIGN: Record<Align, string> = {
start: 'left-0 origin-top-left',
center: 'left-1/2 -translate-x-1/2 origin-top',
end: 'right-0 origin-top-right',
};
export type PopoverProps = {
trigger: (props: { open: boolean; toggle: () => void }) => ReactNode;
children: (close: () => void) => ReactNode;
align?: Align;
width?: number;
className?: string;
};
export function Popover({ trigger, children, align = 'start', width = 320, className }: PopoverProps) {
const [open, setOpen] = useState(false);
const { mounted, visible } = usePresence(open, 180);
const root = useRef<HTMLDivElement>(null);
const close = useCallback(() => setOpen(false), []);
useEscape(open, close);
useClickOutside([root], open, close);
return (
<div ref={root} className="relative inline-block">
{trigger({ open, toggle: () => setOpen((o) => !o) })}
{mounted && (
<div
role="dialog"
style={{ width }}
className={cn(
'absolute top-full z-50 mt-2 rounded-2xl bg-ink-900 p-4 shadow-pop',
ALIGN[align],
visible
? 'scale-100 opacity-100 blur-0 transition-all duration-300 ease-spring'
: 'scale-[0.92] -translate-y-1 opacity-0 blur-[2px] transition-all duration-150 ease-out',
className,
)}
>
{children(close)}
</div>
)}
</div>
);
}
const ROLES = ['Viewer', 'Editor', 'Admin'] as const;
export function PopoverDemo() {
const [email, setEmail] = useState('');
const [role, setRole] = useState<(typeof ROLES)[number]>('Editor');
const [sent, setSent] = useState<string | null>(null);
const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
return (
<div className="flex min-h-[300px] w-full items-start justify-center pt-4">
<Popover
align="center"
trigger={({ open, toggle }) => (
<button
onClick={toggle}
className={cn(
'focus-ring flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all active:scale-[0.97]',
open ? 'bg-ink-800 text-ink-50 shadow-hairline' : 'bg-ink-50 text-ink-950 hover:bg-white',
)}
>
<UserPlus className="h-4 w-4" /> Invite teammate
</button>
)}
>
{(close) => (
<form
onSubmit={(e) => {
e.preventDefault();
if (!valid) return;
setSent(email);
setEmail('');
window.setTimeout(close, 900);
window.setTimeout(() => setSent(null), 1200);
}}
className="space-y-3"
>
<div>
<p className="text-sm font-medium text-ink-50">Invite to Acme Design</p>
<p className="mt-0.5 text-xs text-ink-400">They’ll get an email with a link to join.</p>
</div>
<input
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@company.com"
className="w-full rounded-lg bg-ink-950 px-3 py-2 text-sm text-ink-50 shadow-hairline outline-none transition placeholder:text-ink-500 focus:shadow-glow"
/>
<div className="relative grid grid-cols-3 rounded-lg bg-ink-950 p-0.5 shadow-hairline">
<span
className="absolute inset-y-0.5 left-0.5 w-[calc((100%-4px)/3)] rounded-md bg-ink-800 shadow-hairline transition-transform duration-300 ease-spring"
style={{ transform: `translateX(${ROLES.indexOf(role) * 100}%)` }}
/>
{ROLES.map((r) => (
<button
type="button"
key={r}
onClick={() => setRole(r)}
className={cn('relative z-10 py-1.5 text-xs font-medium transition-colors', role === r ? 'text-ink-50' : 'text-ink-400 hover:text-ink-200')}
>
{r}
</button>
))}
</div>
<div className="flex items-center gap-2 pt-1">
<button
type="button"
onClick={() => navigator.clipboard?.writeText('https://lumen.app/join/acme-7f3k')}
className="focus-ring flex items-center gap-1.5 rounded-lg px-2.5 py-2 text-xs font-medium text-ink-300 transition hover:bg-white/5 hover:text-ink-50"
>
<Link className="h-3.5 w-3.5" /> Copy link
</button>
<button
type="submit"
disabled={!valid && !sent}
className="focus-ring ml-auto flex items-center gap-1.5 rounded-lg bg-brand-500 px-3 py-2 text-xs font-medium text-white transition hover:bg-brand-400 active:scale-[0.97] disabled:opacity-40"
>
{sent && !email ? (
<span key="ok" className="flex animate-scale-in items-center gap-1.5"><Check className="h-3.5 w-3.5" /> Sent</span>
) : (
<span key="send" className="animate-fade-in">Send invite</span>
)}
</button>
</div>
</form>
)}
</Popover>
</div>
);
}components/niceui/ui/motion/hooks.ts
import { useEffect, useState, type ReactNode, type RefObject } from 'react';
import { createPortal } from 'react-dom';
/**
* Keeps an element mounted while it animates out.
* `mounted` controls rendering, `visible` drives the enter/exit classes.
*/
export function usePresence(open: boolean, exitMs = 220) {
const [mounted, setMounted] = useState(open);
const [visible, setVisible] = useState(open);
useEffect(() => {
if (open) {
setMounted(true);
let inner = 0;
const outer = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => setVisible(true));
});
return () => {
cancelAnimationFrame(outer);
cancelAnimationFrame(inner);
};
}
setVisible(false);
const t = window.setTimeout(() => setMounted(false), exitMs);
return () => window.clearTimeout(t);
}, [open, exitMs]);
return { mounted, visible };
}
/** Calls `handler` when Escape is pressed while `active`. */
export function useEscape(active: boolean, handler: () => void) {
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') handler();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [active, handler]);
}
/** Calls `handler` on pointerdown outside every provided ref while `active`. */
export function useClickOutside(
refs: Array<RefObject<HTMLElement>>,
active: boolean,
handler: () => void,
) {
useEffect(() => {
if (!active) return;
const onDown = (e: PointerEvent) => {
const target = e.target as Node;
if (refs.some((r) => r.current?.contains(target))) return;
handler();
};
document.addEventListener('pointerdown', onDown);
return () => document.removeEventListener('pointerdown', onDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, handler]);
}
/**
* Renders overlays into document.body so `position: fixed` is relative to the
* viewport even when an ancestor has a transform (e.g. entrance animations).
*/
export function Portal({ children }: { children: ReactNode }) {
if (typeof document === 'undefined') return null;
return createPortal(children, document.body);
}