Skip to content

Origin-aware Dropdown

A menu that scales out from its trigger.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/origin-dropdown.json

Manual

  1. Install the dependencies

    npm install clsx motion radix-ui 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/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. Add the theme tokens

    This component uses --destructive-text, which shadcn does not define by default. Add it to your global CSS (--destructive-text falls back to your --destructive unless you set it):

    app/globals.css
    @theme inline {
      --color-destructive-text: var(--destructive-text, var(--destructive));
    }
  4. Copy the source

    components/ui/origin-dropdown/origin-dropdown.tsx
    "use client";
    
    import { createContext, useContext, useId, useState, type ComponentProps, type ReactNode } from "react";
    import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
    import { DropdownMenu } from "radix-ui";
    import { cn } from "@/lib/utils";
    import { duration, ease, overlayVariants, spring } from "@/lib/motion";
    
    interface DropdownState {
      open: boolean;
      active: string | null;
      setActive: (id: string | null) => void;
    }
    
    const DropdownContext = createContext<DropdownState>({ open: false, active: null, setActive: () => {} });
    
    export interface OriginDropdownProps extends Omit<ComponentProps<typeof DropdownMenu.Root>, "open" | "defaultOpen" | "onOpenChange"> {
      /** Controlled open state. */
      open?: boolean;
      /** Initial open state when uncontrolled. @default false */
      defaultOpen?: boolean;
      /** Called when the menu opens or closes. */
      onOpenChange?: (open: boolean) => void;
    }
    
    export function OriginDropdown({ open, defaultOpen = false, onOpenChange, children, ...props }: OriginDropdownProps) {
      const [internal, setInternal] = useState(defaultOpen);
      const [active, setActive] = useState<string | null>(null);
      const isOpen = open ?? internal;
    
      return (
        <DropdownMenu.Root
          open={isOpen}
          onOpenChange={(next) => {
            if (open === undefined) setInternal(next);
            if (!next) setActive(null);
            onOpenChange?.(next);
          }}
          {...props}
        >
          <DropdownContext.Provider value={{ open: isOpen, active, setActive }}>{children}</DropdownContext.Provider>
        </DropdownMenu.Root>
      );
    }
    
    /** Wraps your button; pass asChild to use your own element. */
    export const OriginDropdownTrigger = DropdownMenu.Trigger;
    
    export interface OriginDropdownContentProps
      extends Omit<ComponentProps<typeof DropdownMenu.Content>, "forceMount" | "asChild"> {
      /** Alignment against the trigger. @default "start" */
      align?: "start" | "center" | "end";
      /** Gap between the trigger and the menu, in px. @default 6 */
      sideOffset?: number;
    }
    
    export function OriginDropdownContent({
      align = "start",
      sideOffset = 6,
      className,
      children,
      ...props
    }: OriginDropdownContentProps) {
      const { open } = useContext(DropdownContext);
      const reduceMotion = useReducedMotion();
      const groupId = useId();
    
      return (
        <AnimatePresence>
          {open ? (
            <DropdownMenu.Portal forceMount>
              <DropdownMenu.Content forceMount asChild align={align} sideOffset={sideOffset} collisionPadding={8} {...props}>
                <motion.div
                  variants={overlayVariants(reduceMotion)}
                  initial="hidden"
                  animate="visible"
                  exit="exit"
                  // Radix computes the trigger-relative origin, so the menu grows out of whatever opened it.
                  style={{ transformOrigin: "var(--radix-dropdown-menu-content-transform-origin)" }}
                  className={cn(
                    "z-50 min-w-52 overflow-hidden rounded-[10px] border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none",
                    className,
                  )}
                >
                  <motion.div
                    initial="hidden"
                    animate="visible"
                    variants={{ visible: { transition: { staggerChildren: reduceMotion ? 0 : 0.025, delayChildren: 0.03 } } }}
                  >
                    <LayoutGroup id={groupId}>{children}</LayoutGroup>
                  </motion.div>
                </motion.div>
              </DropdownMenu.Content>
            </DropdownMenu.Portal>
          ) : null}
        </AnimatePresence>
      );
    }
    
    const itemEnter = {
      hidden: { opacity: 0, y: -3 },
      visible: { opacity: 1, y: 0, transition: { duration: duration.quick, ease: ease.out } },
    };
    
    export interface OriginDropdownItemProps
      extends Omit<ComponentProps<typeof DropdownMenu.Item>, "asChild" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Icon before the label. */
      icon?: ReactNode;
      /** Keyboard shortcut hint at the end, e.g. "⌘K". */
      shortcut?: string;
      /** Style the item as destructive. @default false */
      destructive?: boolean;
    }
    
    export function OriginDropdownItem({
      icon,
      shortcut,
      destructive = false,
      className,
      children,
      onFocus,
      onBlur,
      ...props
    }: OriginDropdownItemProps) {
      const { active, setActive } = useContext(DropdownContext);
      const reduceMotion = useReducedMotion();
      const id = useId();
    
      return (
        <DropdownMenu.Item
          asChild
          // Radix focuses items on hover and arrow keys; focus drives the gliding highlight.
          onFocus={(event) => {
            onFocus?.(event);
            setActive(id);
          }}
          onBlur={(event) => {
            onBlur?.(event);
            if (active === id) setActive(null);
          }}
          {...props}
        >
          <motion.div
            variants={itemEnter}
            className={cn(
              "relative flex h-9 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-45 [&_svg]:size-4 [&_svg]:shrink-0",
              destructive ? "text-destructive-text" : "text-foreground",
              className,
            )}
          >
            {active === id ? (
              <motion.span
                layoutId="highlight"
                aria-hidden="true"
                transition={reduceMotion ? { duration: 0 } : spring.snappy}
                className={cn("absolute inset-0 rounded-md", destructive ? "bg-destructive/10" : "bg-accent")}
              />
            ) : null}
            {icon ? <span className={cn("relative flex", destructive ? "" : "text-muted-foreground")}>{icon}</span> : null}
            <span className="relative flex-1 truncate">{children}</span>
            {shortcut ? (
              <kbd className="relative font-sans text-xs tracking-widest text-muted-foreground">{shortcut}</kbd>
            ) : null}
          </motion.div>
        </DropdownMenu.Item>
      );
    }
    
    export function OriginDropdownSeparator({ className, ...props }: ComponentProps<typeof DropdownMenu.Separator>) {
      return <DropdownMenu.Separator className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />;
    }
    
    export function OriginDropdownLabel({ className, ...props }: ComponentProps<typeof DropdownMenu.Label>) {
      return <DropdownMenu.Label className={cn("px-2.5 pt-1.5 pb-1 text-xs font-medium text-muted-foreground", className)} {...props} />;
    }

Usage

import {
  OriginDropdown,
  OriginDropdownContent,
  OriginDropdownItem,
  OriginDropdownTrigger,
} from "@/components/ui/origin-dropdown/origin-dropdown";

export function RowActions() {
  return (
    <OriginDropdown>
      <OriginDropdownTrigger asChild>
        <button>Actions</button>
      </OriginDropdownTrigger>
      <OriginDropdownContent align="end">
        <OriginDropdownItem onSelect={duplicate}>Duplicate</OriginDropdownItem>
        <OriginDropdownItem destructive onSelect={remove}>Delete</OriginDropdownItem>
      </OriginDropdownContent>
    </OriginDropdown>
  );
}

API reference

OriginDropdown

PropTypeDefaultDescription
openboolean–Controlled open state.
defaultOpenbooleanfalseInitial open state when uncontrolled.
onOpenChange(open: boolean) => void–Called when the menu opens or closes.

Also accepts every prop of Radix DropdownMenu.Root, including className and disabled.

OriginDropdownContent

PropTypeDefaultDescription
align"start" | "center" | "end""start"Alignment against the trigger.
sideOffsetnumber6Gap between the trigger and the menu, in px.

Also accepts every prop of Radix DropdownMenu.Content, including className and disabled.

OriginDropdownItem

PropTypeDefaultDescription
iconReactNode–Icon before the label.
shortcutstring–Keyboard shortcut hint at the end, e.g. "⌘K".
destructivebooleanfalseStyle the item as destructive.

Also accepts every prop of Radix DropdownMenu.Item, including className and disabled.

  • The menu scales from --radix-dropdown-menu-content-transform-origin, so it grows from the trigger's edge even when Radix flips it to fit.
  • Use onSelect on items for actions; Radix closes the menu afterwards.

Accessibility

  • Built on Radix DropdownMenu: role="menu" and "menuitem", arrow keys, typeahead, Home and End, and Escape returns focus to the trigger.
  • Disabled items are announced and skipped, and destructive items keep their label, so meaning isn't carried by color alone.
  • The highlight follows keyboard focus as well as the pointer.
  • With reduced motion enabled, the menu fades without scaling, items appear together and the highlight jumps.