Skip to content

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.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and 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.ts
    import 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 } },
      };
    }
  3. 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

PropTypeDefaultDescription
code*string–The invite code that is revealed and copied.
labelstring"Your invite code"Text on the small pill above the code. Pressing it folds the code away again.
revealLabelstring"Your invite is ready"Text on the closed pill.
copyLabelstring"COPY"Copy button text.
copiedLabelstring"COPIED"Copy button text after a successful copy.
openboolean–Controlled open state.
defaultOpenbooleanfalseWhether 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.
accentstring"#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.