Tone popovers
Five tone colored popovers: primary, success, warning, danger and info. Each renders a soft toned surface, border and icon chip from its semantic tone token, opens on click and closes on outside click or Escape.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/popover-toneDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/popover-tone.tsx"use client"
import * as React from "react"
import { AlertTriangle, CheckCircle2, Info, Sparkles, XCircle } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Tone popover family: 5 semantically toned info panels. Each export is a complete
popover: internal open/closed state (uncontrolled defaultOpen or controlled
open/onOpenChange), NO radix or portal - a relative inline-flex wrapper + a panel
absolutely positioned BELOW the trigger (top-full mt-2). The trigger toggles on
CLICK; it closes on an outside click (window pointerdown, with inner clicks
ignored via the wrapper ref) and on Escape. The trigger is a real button, colored
with its tone. The panel sits on the -soft/-soft-foreground background of the
relevant tone token: an icon chip + a title + text. AnimatePresence fade+scale
(from above); only a fade under reduced motion. Color comes ONLY from semantic
tokens, via alpha color-mix - NO hex/oklch/raw alpha. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const panelSize: Record<StyledSize, string> = {
sm: "w-56",
md: "w-64",
lg: "w-72",
xl: "w-80",
}
interface PopoverProps {
size?: StyledSize
trigger?: React.ReactNode
children?: React.ReactNode
className?: string
open?: boolean
defaultOpen?: boolean
onOpenChange?: (o: boolean) => void
}
/* Defines every surface of a single tone: trigger, panel, icon chip and icon. The colours come only from the relevant semantic token (directly from -soft/-soft-foreground, or through color-mix for alpha). */
interface ToneStyle {
label: string
trigger: string
panel: string
chip: string
icon: React.ReactNode
}
const panelBase =
"absolute left-0 top-full z-50 mt-2 flex origin-top gap-3 rounded-xl border p-4 text-sm shadow-lg outline-none [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const chipBase =
"flex size-9 shrink-0 items-center justify-center rounded-lg [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const triggerBase =
"inline-flex h-9 shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg border px-4 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
/* Controlled/uncontrolled acik durum yonetimi. */
function usePopoverState(props: PopoverProps) {
const { open, defaultOpen, onOpenChange } = props
const isControlled = open !== undefined
const [internal, setInternal] = React.useState(defaultOpen ?? false)
const isOpen = isControlled ? open : internal
const setOpen = React.useCallback(
(next: boolean) => {
if (!isControlled) setInternal(next)
onOpenChange?.(next)
},
[isControlled, onOpenChange]
)
return { isOpen, setOpen }
}
const bespokeMotion = {
initial: { opacity: 0, scale: 0.96, y: -6 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.96, y: -6 },
transition: { type: "spring" as const, stiffness: 340, damping: 26 },
}
/* Shared toned shell: a relative wrapper plus a toned trigger plus an AnimatePresence panel. */
function TonePopoverShell({
props,
tone,
heading,
}: {
props: PopoverProps
tone: ToneStyle
heading: string
}) {
const { size = "md", trigger, children, className } = props
const { isOpen, setOpen } = usePopoverState(props)
const reduce = useReducedMotion()
const wrapperRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (!isOpen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false)
}
const onPointer = (e: PointerEvent) => {
const node = wrapperRef.current
if (node && e.target instanceof Node && !node.contains(e.target)) {
setOpen(false)
}
}
window.addEventListener("keydown", onKey)
window.addEventListener("pointerdown", onPointer)
return () => {
window.removeEventListener("keydown", onKey)
window.removeEventListener("pointerdown", onPointer)
}
}, [isOpen, setOpen])
const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
const motionProps = reduce ? fade : bespokeMotion
return (
<div ref={wrapperRef} data-slot="styled-popover" data-tone={tone.label} className="relative inline-flex">
<span
data-slot="styled-popover-trigger"
className="inline-flex"
onClick={() => setOpen(!isOpen)}
>
{trigger ?? (
<button
type="button"
aria-haspopup="dialog"
aria-expanded={isOpen}
className={cn(triggerBase, tone.trigger)}
>
{tone.icon}
{tone.label}
</button>
)}
</span>
<AnimatePresence>
{isOpen ? (
<motion.div
data-slot="styled-popover-content"
role="dialog"
className={cn(panelBase, panelSize[size], tone.panel, className)}
initial={motionProps.initial}
animate={motionProps.animate}
exit={motionProps.exit}
transition={reduce ? { duration: 0.12 } : bespokeMotion.transition}
>
<span data-slot="styled-popover-icon" className={cn(chipBase, tone.chip)}>
{tone.icon}
</span>
<div className="flex flex-col gap-1">
<p className="font-semibold">{heading}</p>
<p className="opacity-80">
{children ?? "A soft, tone colored panel anchored to its trigger."}
</p>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
/* Primary: a neutral soft surface derived from the primary token (there is no primary-soft token, hence alpha through color-mix). */
export function PrimaryPopover(props: PopoverProps) {
return (
<TonePopoverShell
props={props}
heading="Heads up"
tone={{
label: "Primary",
trigger:
"border-[color-mix(in_oklab,var(--color-primary)_22%,transparent)] bg-[color-mix(in_oklab,var(--color-primary)_10%,transparent)] text-primary hover:bg-[color-mix(in_oklab,var(--color-primary)_16%,transparent)]",
panel:
"border-[color-mix(in_oklab,var(--color-primary)_20%,transparent)] bg-[color-mix(in_oklab,var(--color-primary)_9%,transparent)] text-primary",
chip: "bg-[color-mix(in_oklab,var(--color-primary)_16%,transparent)] text-primary",
icon: <Sparkles />,
}}
/>
)
}
/* Success: success-soft zemin + success-soft-foreground metin. */
export function SuccessPopover(props: PopoverProps) {
return (
<TonePopoverShell
props={props}
heading="All set"
tone={{
label: "Success",
trigger:
"border-[color-mix(in_oklab,var(--color-success)_30%,transparent)] bg-success-soft text-success-soft-foreground hover:bg-[color-mix(in_oklab,var(--color-success)_16%,transparent)]",
panel:
"border-[color-mix(in_oklab,var(--color-success)_26%,transparent)] bg-success-soft text-success-soft-foreground",
chip: "bg-[color-mix(in_oklab,var(--color-success)_18%,transparent)] text-success-soft-foreground",
icon: <CheckCircle2 />,
}}
/>
)
}
/* Warning: warning-soft zemin + warning-soft-foreground metin. */
export function WarningPopover(props: PopoverProps) {
return (
<TonePopoverShell
props={props}
heading="Take care"
tone={{
label: "Warning",
trigger:
"border-[color-mix(in_oklab,var(--color-warning)_30%,transparent)] bg-warning-soft text-warning-soft-foreground hover:bg-[color-mix(in_oklab,var(--color-warning)_16%,transparent)]",
panel:
"border-[color-mix(in_oklab,var(--color-warning)_26%,transparent)] bg-warning-soft text-warning-soft-foreground",
chip: "bg-[color-mix(in_oklab,var(--color-warning)_18%,transparent)] text-warning-soft-foreground",
icon: <AlertTriangle />,
}}
/>
)
}
/* Danger: danger-soft zemin + danger-soft-foreground metin. */
export function DangerPopover(props: PopoverProps) {
return (
<TonePopoverShell
props={props}
heading="Something broke"
tone={{
label: "Danger",
trigger:
"border-[color-mix(in_oklab,var(--color-danger)_30%,transparent)] bg-danger-soft text-danger-soft-foreground hover:bg-[color-mix(in_oklab,var(--color-danger)_16%,transparent)]",
panel:
"border-[color-mix(in_oklab,var(--color-danger)_26%,transparent)] bg-danger-soft text-danger-soft-foreground",
chip: "bg-[color-mix(in_oklab,var(--color-danger)_18%,transparent)] text-danger-soft-foreground",
icon: <XCircle />,
}}
/>
)
}
/* Info: info-soft zemin + info-soft-foreground metin. */
export function InfoPopover(props: PopoverProps) {
return (
<TonePopoverShell
props={props}
heading="Good to know"
tone={{
label: "Info",
trigger:
"border-[color-mix(in_oklab,var(--color-info)_30%,transparent)] bg-info-soft text-info-soft-foreground hover:bg-[color-mix(in_oklab,var(--color-info)_16%,transparent)]",
panel:
"border-[color-mix(in_oklab,var(--color-info)_26%,transparent)] bg-info-soft text-info-soft-foreground",
chip: "bg-[color-mix(in_oklab,var(--color-info)_18%,transparent)] text-info-soft-foreground",
icon: <Info />,
}}
/>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Primary
A neutral primary tone surface with a spark icon.
import { PrimaryPopover } from "@/components/ui/popover-tone"
<PrimaryPopover>A soft, tone colored panel anchored to its trigger.</PrimaryPopover>Success
A success soft surface with a check icon.
import { SuccessPopover } from "@/components/ui/popover-tone"
<SuccessPopover>A soft, tone colored panel anchored to its trigger.</SuccessPopover>Warning
A warning soft surface with a triangle icon.
import { WarningPopover } from "@/components/ui/popover-tone"
<WarningPopover>A soft, tone colored panel anchored to its trigger.</WarningPopover>Danger
A danger soft surface with an error icon.
import { DangerPopover } from "@/components/ui/popover-tone"
<DangerPopover>A soft, tone colored panel anchored to its trigger.</DangerPopover>Info
An info soft surface with an info icon.
import { InfoPopover } from "@/components/ui/popover-tone"
<InfoPopover>A soft, tone colored panel anchored to its trigger.</InfoPopover>ai2 Tone popovers: 5 styled variations on the token system
The ai2 Tone popovers are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around soft, semantic tone colored information panels. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion fades and scales the panel in from the trigger. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the scale is skipped and the panel appears instantly.
What is in the ai2 Tone popovers?
5 exports in one file: Primary, Success, Warning, Danger and Info. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion fades and scales the panel in from the trigger.
- Reduced-motion aware: Under prefers-reduced-motion, the scale is skipped and the panel appears instantly.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Tone popovers sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.