Buttons & Actionsv0.4.0New
Invite Code Reveal
A ready pill that morphs into a labeled invite code with a copy button.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/invite-code-reveal.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/clipboard.ts"use client"; import { useCallback, useEffect, useRef, useState } from "react"; /** * Writes text to the clipboard. Uses the async Clipboard API where it exists and falls back to a * hidden textarea with `document.execCommand("copy")` in non-secure contexts (plain HTTP, some * embedded webviews). Call it from an event handler only. Rejects when the copy is refused. */ export async function writeClipboard(text: string): Promise<void> { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; } const textarea = document.createElement("textarea"); textarea.value = text; textarea.setAttribute("readonly", ""); textarea.style.position = "fixed"; textarea.style.opacity = "0"; document.body.appendChild(textarea); textarea.select(); const ok = document.execCommand("copy"); textarea.remove(); if (!ok) throw new Error("Copy command was rejected"); } export interface UseCopyOptions { /** Milliseconds before `copied` returns to false. @default 2000 */ resetAfter?: number; /** Called with the copied text after a successful copy. */ onCopy?: (text: string) => void; } export interface UseCopyResult { /** True from a successful copy until `resetAfter` has passed. */ copied: boolean; /** Copies the text. Resolves to false when the browser refuses. Call it from an event handler. */ copy: () => Promise<boolean>; /** Returns to the idle state right away. */ reset: () => void; } /** Copy-to-clipboard state for buttons: `copied` flips to true after a successful copy and resets on a timer. */ export function useCopy(text: string, { resetAfter = 2000, onCopy }: UseCopyOptions = {}): UseCopyResult { const [copied, setCopied] = useState(false); const timer = useRef<number | undefined>(undefined); const mounted = useRef(true); const latest = useRef({ text, resetAfter, onCopy }); useEffect(() => { latest.current = { text, resetAfter, onCopy }; }); useEffect(() => { mounted.current = true; return () => { mounted.current = false; window.clearTimeout(timer.current); }; }, []); const reset = useCallback(() => { window.clearTimeout(timer.current); setCopied(false); }, []); const copy = useCallback(async () => { const current = latest.current; try { await writeClipboard(current.text); } catch { return false; } if (!mounted.current) return true; setCopied(true); current.onCopy?.(current.text); window.clearTimeout(timer.current); timer.current = window.setTimeout(() => setCopied(false), current.resetAfter); return true; }, []); return { copied, copy, reset }; }lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/invite-code-reveal/invite-code-reveal.tsx"use client"; import { useEffect, useId, useRef, useState, type ComponentProps, type CSSProperties, type KeyboardEvent } from "react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion, type Variants } from "motion/react"; import { ArrowDown, Check } from "lucide-react"; import { cn } from "@/lib/utils"; import { useCopy } from "@/lib/clipboard"; import { duration, ease, spring } from "@/lib/motion"; export interface InviteCodeRevealProps extends Omit<ComponentProps<"div">, "children" | "onCopy"> { /** The invite code that is revealed and copied. */ code: string; /** Text on the small pill above the code. Pressing it folds the code away again. @default "Your invite code" */ label?: string; /** Text on the closed pill. @default "Your invite is ready" */ revealLabel?: string; /** Copy button text. @default "COPY" */ copyLabel?: string; /** Copy button text after a successful copy. @default "COPIED" */ copiedLabel?: string; /** Controlled open state. */ open?: boolean; /** Whether the code starts revealed when uncontrolled. @default false */ defaultOpen?: boolean; /** Called when the pill opens or closes. */ onOpenChange?: (open: boolean) => void; /** Called with the code after it is copied. */ onCopy?: (code: string) => void; /** Color of the action button, as any CSS color. Pick one that keeps white text readable. @default "#15803d" */ accent?: string; } const focusRing = "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"; export function InviteCodeReveal({ code, label = "Your invite code", revealLabel = "Your invite is ready", copyLabel = "COPY", copiedLabel = "COPIED", open, defaultOpen = false, onOpenChange, onCopy, accent = "#15803d", className, style, onKeyDown, ...props }: InviteCodeRevealProps) { const [internal, setInternal] = useState(defaultOpen); const isOpen = open ?? internal; const reduceMotion = useReducedMotion(); const id = useId(); const { copied, copy, reset } = useCopy(code, { resetAfter: 1600, onCopy }); const revealRef = useRef<HTMLButtonElement>(null); const labelRef = useRef<HTMLButtonElement>(null); /** Set when the user toggled, so focus follows them; controlled changes from outside leave focus alone. */ const moveFocus = useRef(false); useEffect(() => { if (!moveFocus.current) return; moveFocus.current = false; (isOpen ? labelRef : revealRef).current?.focus({ preventScroll: true }); }, [isOpen]); function setOpen(next: boolean) { moveFocus.current = true; if (!next) reset(); if (open === undefined) setInternal(next); onOpenChange?.(next); } function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) { onKeyDown?.(event); if (event.key === "Escape" && isOpen && !event.defaultPrevented) { event.preventDefault(); setOpen(false); } } const morph = reduceMotion ? { duration: 0 } : spring.smooth; // The resting values are the same in both modes: they are server-rendered as inline styles, so // only where a swap starts and ends may depend on reduced motion. const rest = { opacity: 1, y: 0, filter: "blur(0px)" }; const swap: Variants = reduceMotion ? { initial: { ...rest, opacity: 0 }, animate: rest, exit: { ...rest, opacity: 0 } } : { initial: { opacity: 0, y: 6, filter: "blur(4px)" }, animate: rest, exit: { opacity: 0, y: -6, filter: "blur(4px)" } }; const swapTransition = { duration: duration.quick, ease: ease.out }; return ( <div onKeyDown={handleKeyDown} style={{ "--invite-accent": accent, ...style } as CSSProperties} className={cn("relative w-full max-w-[360px] pt-10", className)} {...props} > {/* The label pill rises out of the main pill's top edge and settles 8px above it. */} <AnimatePresence initial={false}> {isOpen ? ( <motion.div key="label" className="absolute inset-x-0 top-0 flex justify-center" initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 26, scale: 0.9 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 26, scale: 0.9 }} transition={reduceMotion ? { duration: duration.quick } : spring.smooth} > <button ref={labelRef} type="button" aria-expanded="true" aria-controls={`${id}-code`} onClick={() => setOpen(false)} className={cn( "relative h-8 cursor-pointer rounded-full border border-white bg-muted px-3.5 text-[13px] font-medium text-foreground/70 shadow-[0_1px_2px_rgb(0_0_0/0.06),0_3px_10px_-3px_rgb(0_0_0/0.14)] transition-colors hover:text-foreground", "after:absolute after:-inset-x-1 after:-inset-y-1.5 after:content-['']", focusRing, )} > {label} </button> </motion.div> ) : null} </AnimatePresence> <div className="relative flex h-14 items-center rounded-full bg-muted pr-2 pl-5 shadow-[0_1px_2px_rgb(0_0_0/0.06),0_6px_18px_-6px_rgb(0_0_0/0.16)]"> {/* Solid border while closed; the dashed one wipes in over it when the code shows. */} <span aria-hidden="true" className={cn("pointer-events-none absolute inset-0 rounded-full border border-white transition-opacity duration-200", isOpen && "opacity-0")} /> <motion.span aria-hidden="true" initial={false} animate={{ clipPath: isOpen ? "inset(0 0% 0 0 round 9999px)" : "inset(0 100% 0 0 round 9999px)", opacity: isOpen ? 1 : 0 }} transition={reduceMotion ? { duration: duration.quick } : { duration: duration.slow, ease: ease.out }} className="pointer-events-none absolute inset-0 rounded-full border-[1.5px] border-dashed border-foreground/30" /> <span id={`${id}-code`} className="relative grid min-w-0 flex-1 items-center *:[grid-area:1/1]"> <AnimatePresence initial={false}> {isOpen ? ( <motion.span key="code" variants={swap} initial="initial" animate="animate" exit="exit" transition={swapTransition} className="truncate font-mono text-[15px] font-bold tracking-wide text-foreground select-text" > {code} </motion.span> ) : ( <motion.span key="reveal" aria-hidden="true" variants={swap} initial="initial" animate="animate" exit="exit" transition={swapTransition} className="truncate text-[15px] font-medium text-foreground" > {revealLabel} </motion.span> )} </AnimatePresence> </span> <LayoutGroup id={id}> {isOpen ? ( <motion.button key="copy" type="button" layoutId="action" transition={morph} style={{ borderRadius: 9999 }} onClick={() => void copy()} className={cn( "relative ml-3 flex h-10 shrink-0 cursor-pointer items-center justify-center bg-(--invite-accent) px-4 text-xs font-bold tracking-[0.08em] text-white", "after:absolute after:-inset-y-0.5 after:inset-x-0 after:content-['']", focusRing, "focus-visible:ring-offset-muted", )} > <motion.span layout="position" transition={morph} className="grid *:[grid-area:1/1]"> <span className="invisible" aria-hidden="true"> {copyLabel} </span> <span className="invisible inline-flex items-center gap-1" aria-hidden="true"> <Check className="size-3.5" /> {copiedLabel} </span> <AnimatePresence initial={false}> <motion.span key={copied ? "copied" : "copy"} variants={swap} initial="initial" animate="animate" exit="exit" transition={swapTransition} className="inline-flex items-center justify-center gap-1" > {copied ? <Check className="size-3.5" strokeWidth={3} aria-hidden="true" /> : null} {copied ? copiedLabel : copyLabel} </motion.span> </AnimatePresence> </motion.span> </motion.button> ) : ( <motion.span key="arrow" aria-hidden="true" layoutId="action" transition={morph} style={{ borderRadius: 9999 }} className="ml-3 flex size-10 shrink-0 items-center justify-center bg-(--invite-accent) text-white" > <motion.span layout="position" transition={morph}> <ArrowDown className="size-[18px]" strokeWidth={2.5} /> </motion.span> </motion.span> )} </LayoutGroup> {/* While closed the whole pill is one button. */} {isOpen ? null : ( <button ref={revealRef} type="button" aria-expanded="false" aria-controls={`${id}-code`} onClick={() => setOpen(true)} className={cn("absolute inset-0 cursor-pointer rounded-full active:bg-foreground/[0.03]", focusRing)} > <span className="sr-only">{revealLabel}</span> </button> )} </div> <span role="status" aria-live="polite" className="sr-only"> {copied ? "Code copied" : ""} </span> </div> ); }
Usage
import { InviteCodeReveal } from "@/components/ui/invite-code-reveal/invite-code-reveal";
export function Invite() {
return <InviteCodeReveal code="BRIGHT-OTTER-42" onCopy={(code) => console.info("copied", code)} />;
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
code* | string | – | The invite code that is revealed and copied. |
label | string | "Your invite code" | Text on the small pill above the code. Pressing it folds the code away again. |
revealLabel | string | "Your invite is ready" | Text on the closed pill. |
copyLabel | string | "COPY" | Copy button text. |
copiedLabel | string | "COPIED" | Copy button text after a successful copy. |
open | boolean | – | Controlled open state. |
defaultOpen | boolean | false | Whether the code starts revealed when uncontrolled. |
onOpenChange | (open: boolean) => void | – | Called when the pill opens or closes. |
onCopy | (code: string) => void | – | Called with the code after it is copied. |
accent | string | "#15803d" | Color of the action button, as any CSS color. Pick one that keeps white text readable. |
Also accepts every native <div> attribute, including ref and className.
- The green circle and the copy button are one shared layout element, so the circle stretches into the pill with a transform, not a width animation.
- The component keeps room for the label pill above itself in both states, so nothing around it shifts when it opens.
- Copy uses the shared useCopy hook, which falls back to document.execCommand("copy") outside secure contexts. The copied state lasts 1.6 seconds.
- accent takes any CSS color. The button text is white, so keep the accent dark enough for 4.5:1 contrast.
Accessibility
- While closed, the whole pill is one button with aria-expanded="false". Once open, the label pill above is the toggle (aria-expanded="true") and the copy button is a separate button.
- Opening moves focus to the label pill, and closing returns it to the closed pill. Escape closes from anywhere inside.
- The code is real, selectable text, so it can be read and copied by hand.
- A polite live region says "Code copied" once after each successful copy.
- With reduced motion enabled, the two states crossfade: no rise, blur or stretch.