# How to Implement Origin‑Aware Dropdown and Tooltip Animations

> Learn how to implement origin-aware dropdown and tooltip animations. Style trigger elements, animate scale and opacity for smooth UI effects.

- Repository: [Emil Kowalski/skills](https://github.com/emilkowalski/skills)
- Tags: how-to-guide
- Published: 2026-08-01

---

**Set `transform-origin` to your trigger element's position, then animate scale (0.95 → 1) with opacity using an ease‑out curve under 300 ms.**

Origin‑aware animations make dropdowns and tooltips feel physically connected to their triggers rather than appearing from nowhere. This guide walks through implementation patterns based on the animation standards defined in `emilkowalski/skills`, with runnable React and CSS examples.

---

## What Makes an Animation "Origin‑Aware"

The core principle, documented in [`skills/review-animations/SKILL.md`](https://github.com/emilkowalski/skills/blob/main/skills/review-animations/SKILL.md), is that **popovers, dropdowns, and tooltips scale from their trigger**, not from the center of the revealed element. This creates spatial continuity—the UI element appears to grow *out of* the button or icon that summoned it.

The repository explicitly forbids animating from `scale(0)`, which produces an unnatural "pop‑in" effect. Instead, start from `scale(0.95)` or similar, as detailed in lines 31‑52 of the animation skill sheet.

---

## Calculating the Transform Origin

Your popup needs a dynamic `transform-origin` based on the trigger's position relative to the viewport and the popup's own rendered position.

### Step‑by‑Step Logic

1. **Measure the trigger** using `getBoundingClientRect()` to get `left`, `top`, `width`, and `height`.
2. **Measure the popup** after it renders (post‑positioning).
3. **Compute the offset** between trigger center/bottom and popup position.
4. **Apply via CSS custom property** `--origin` for clean separation of concerns.

This calculation must run after layout—typically in `useEffect` (React) or a `requestAnimationFrame` callback.

---

## React Implementation: Origin‑Aware Dropdown

```tsx
import { useRef, useEffect, useState } from 'react';

interface DropdownProps {
  triggerId: string;
  children: React.ReactNode;
  isOpen: boolean;
}

function Dropdown({ triggerId, children, isOpen }: DropdownProps) {
  const triggerRef = useRef<HTMLButtonElement>(null);
  const popupRef = useRef<HTMLDivElement>(null);
  const [origin, setOrigin] = useState('top left');

  useEffect(() => {
    const trigger = triggerRef.current;
    const popup = popupRef.current;
    if (!trigger || !popup) return;

    const { left, top, width, height } = trigger.getBoundingClientRect();
    const popupRect = popup.getBoundingClientRect();

    // Bottom‑center origin: scale from trigger's bottom edge
    const offsetX = left + width / 2 - popupRect.left;
    const offsetY = top + height - popupRect.top;
    
    setOrigin(`${offsetX}px ${offsetY}px`);
  }, [isOpen]);

  return (
    <>
      <button id={triggerId} ref={triggerRef}>
        Open Menu
      </button>
      {isOpen && (
        <div
          ref={popupRef}
          className="dropdown"
          style={{ '--origin': origin } as React.CSSProperties}
          data-open={isOpen}
        >
          {children}
        </div>
      )}
    </>
  );
}

```

---

## CSS Animation Pattern

The styles below implement the origin‑aware animation with the timing and easing recommended in the repository standards.

```css
.dropdown {
  position: absolute;
  /* Assumes you handle positioning separately (Popper.js, Floating UI, etc.) */
  
  transform-origin: var(--origin, top left);
  
  /* Initial state: slightly scaled down, invisible */
  opacity: 0;
  transform: scale(0.95);
  
  /* Animate both properties with ease-out for snappiness */
  transition: 
    transform 180ms ease-out, 
    opacity 180ms ease-out;
}

/* Visible state matches natural scale and full opacity */
.dropdown[data-open="true"] {
  opacity: 1;
  transform: scale(1);
}

```

**Why this works:**

- **`transform-origin: var(--origin)`** — dynamically anchors the scaling motion to the trigger's location.
- **`scale(0.95)` start** — respects the prohibition against `scale(0)` in [`skills/review-animations/SKILL.md`](https://github.com/emilkowalski/skills/blob/main/skills/review-animations/SKILL.md) lines 31‑52.
- **`ease-out` curve** — avoids the sluggish feeling of `ease-in`, as noted in [`skills/emil-design-eng/SKILL.md`](https://github.com/emilkowalski/skills/blob/main/skills/emil-design-eng/SKILL.md) lines 46‑53.
- **180 ms duration** — stays well under the 300 ms performance budget defined in [`skills/review-animations/STANDARDS.md`](https://github.com/emilkowalski/skills/blob/main/skills/review-animations/STANDARDS.md) line 49.

---

## Tooltip Variant: Same Principle, Different Origin

Tooltips typically appear above or below their anchor, requiring a slightly different origin calculation.

```tsx
import { useRef, useEffect, useState } from 'react';

interface TooltipProps {
  anchorRef: React.RefObject<HTMLElement>;
  text: string;
  placement?: 'top' | 'bottom';
}

function Tooltip({ anchorRef, text, placement = 'top' }: TooltipProps) {
  const tooltipRef = useRef<HTMLDivElement>(null);
  const [origin, setOrigin] = useState('bottom center');

  useEffect(() => {
    const anchor = anchorRef.current;
    const tooltip = tooltipRef.current;
    if (!anchor || !tooltip) return;

    const { left, top, width, height } = anchor.getBoundingClientRect();
    const tooltipRect = tooltip.getBoundingClientRect();

    const offsetX = left + width / 2 - tooltipRect.left;

    let offsetY: number;
    if (placement === 'top') {
      offsetY = top - tooltipRect.bottom; // Scale from anchor top
    } else {
      offsetY = top + height - tooltipRect.top; // Scale from anchor bottom
    }

    setOrigin(`${offsetX}px ${offsetY}px`);
  }, [placement, text]);

  return (
    <div
      ref={tooltipRef}
      className="tooltip"
      style={{ '--origin': origin } as React.CSSProperties}
      role="tooltip"
    >
      {text}
    </div>
  );
}

```

```css
.tooltip {
  position: absolute;
  z-index: 50;
  padding: 6px 12px;
  background-color: #1a1a1a;
  color: #ffffff;
  font-size: 13px;
  border-radius: 6px;
  white-space: nowrap;
  
  transform-origin: var(--origin, bottom center);
  opacity: 0;
  transform: scale(0.95);
  transition: 
    transform 150ms ease-out, 
    opacity 150ms ease-out;
}

.tooltip[data-visible="true"] {
  opacity: 1;
  transform: scale(1);
}

```

---

## Architectural Considerations

### Performance Budget

The [`skills/review-animations/STANDARDS.md`](https://github.com/emilkowalski/skills/blob/main/skills/review-animations/STANDARDS.md) file establishes strict timing constraints. **Keep all dropdown and tooltip animations under 300 ms**, with 150‑200 ms being the sweet spot for perceived responsiveness.

### Easing Curves Matter

[`skills/emil-design-eng/SKILL.md`](https://github.com/emilkowalski/skills/blob/main/skills/emil-design-eng/SKILL.md) explicitly warns against `ease-in` for UI entrances—it feels slow because motion starts sluggish and accelerates. **Always use `ease-out`** or `cubic-bezier(0, 0, 0.2, 1)` for entrance animations, which provides immediate response with gentle deceleration.

### Accessibility Requirements

Even with polished animations, don't neglect semantic structure. The [`skills/pick-ui-library/SKILL.md`](https://github.com/emilkowalski/skills/blob/main/skills/pick-ui-library/SKILL.md) file recommends handling focus management manually for dropdowns—ensure:

- `aria-expanded` toggles on the trigger.
- Focus moves into the popup on open.
- `Escape` closes the popup and returns focus.
- Screen readers announce state changes.

---

## Complete Working Example

Here's a self‑contained pattern combining both dropdown and tooltip approaches:

```tsx
import { useRef, useState, useEffect } from 'react';

function OriginAwarePopup({
  trigger,
  children,
  strategy = 'dropdown'
}: {
  trigger: React.ReactNode;
  children: React.ReactNode;
  strategy?: 'dropdown' | 'tooltip';
}) {
  const triggerRef = useRef<HTMLDivElement>(null);
  const popupRef = useRef<HTMLDivElement>(null);
  const [isOpen, setIsOpen] = useState(false);
  const [origin, setOrigin] = useState('top center');

  useEffect(() => {
    if (!isOpen) return;
    
    const trigger = triggerRef.current;
    const popup = popupRef.current;
    if (!trigger || !popup) return;

    const tRect = trigger.getBoundingClientRect();
    const pRect = popup.getBoundingClientRect();

    const originX = tRect.left + tRect.width / 2 - pRect.left;
    const originY = strategy === 'tooltip' 
      ? tRect.top - pRect.bottom 
      : tRect.top + tRect.height - pRect.top;

    setOrigin(`${originX}px ${originY}px`);
  }, [isOpen, strategy]);

  const duration = strategy === 'tooltip' ? 150 : 180;

  return (
    <div className="origin-aware-container">
      <div 
        ref={triggerRef}
        onClick={() => setIsOpen(!isOpen)}
        onMouseEnter={() => strategy === 'tooltip' && setIsOpen(true)}
        onMouseLeave={() => strategy === 'tooltip' && setIsOpen(false)}
      >
        {trigger}
      </div>
      
      {isOpen && (
        <div
          ref={popupRef}
          className="origin-popup"
          style={{
            '--origin': origin,
            '--duration': `${duration}ms`
          } as React.CSSProperties}
          data-open={isOpen}
        >
          {children}
        </div>
      )}
    </div>
  );
}

```

```css
.origin-popup {
  position: absolute;
  transform-origin: var(--origin);
  opacity: 0;
  transform: scale(0.95);
  transition: 
    transform var(--duration, 180ms) ease-out,
    opacity var(--duration, 180ms) ease-out;
}

.origin-popup[data-open="true"] {
  opacity: 1;
  transform: scale(1);
}

```

---

## Summary

- **Origin‑aware animations** set `transform-origin` to the trigger's position, making popups feel physically anchored.
- **Never animate from `scale(0)`** — start from `0.95` per [`skills/review-animations/SKILL.md`](https://github.com/emilkowalski/skills/blob/main/skills/review-animations/SKILL.md).
- **Use `ease-out` easing** and keep duration **under 300 ms** (ideally 150‑180 ms) per [`skills/emil-design-eng/SKILL.md`](https://github.com/emilkowalski/skills/blob/main/skills/emil-design-eng/SKILL.md) and [`STANDARDS.md`](https://github.com/emilkowalski/skills/blob/main/STANDARDS.md).
- **Pass the computed origin** via CSS custom properties for clean, performant updates.
- **Preserve accessibility** with proper ARIA attributes and focus management as recommended in [`skills/pick-ui-library/SKILL.md`](https://github.com/emilkowalski/skills/blob/main/skills/pick-ui-library/SKILL.md).

---

## Frequently Asked Questions

### What is the difference between origin‑aware and centered animations?

**Centered animations scale from the middle of the popup element**, which disconnects the visual motion from the user's action point. **Origin‑aware animations scale from the trigger's location**, creating spatial continuity. As documented in [`skills/apple-design/SKILL.md`](https://github.com/emilkowalski/skills/blob/main/skills/apple-design/SKILL.md) line 138, this "anchor interactions to their source" principle is fundamental to polished, Apple‑style interfaces.

### Why can't I use `scale(0)` for my dropdown entrance?

Animating from `scale(0)` creates a "pop‑in" effect that violates physical realism—the element appears to materialize from a dimensionless point rather than emerging from its source. The [`skills/review-animations/SKILL.md`](https://github.com/emilkowalski/skills/blob/main/skills/review-animations/SKILL.md) guidelines explicitly forbid this for UI primitives, mandating a near‑complete starting scale (0.9‑0.97) instead.

### How do I handle origin calculation for dynamic positions?

For popups that may flip based on viewport space (e.g., dropdowns that open upward when near screen bottom), **recalculate origin whenever position changes**. Use a layout effect that runs after the popup's final position is determined, passing the new trigger‑popup relationship into your `--origin` custom property.

### Does this approach work with CSS‑only solutions?

Pure CSS can handle origin‑aware animations **only when the trigger‑popup relationship is fixed and known ahead of time** (e.g., a tooltip always below a specific button). For dynamic or reusable components, JavaScript measurement via `getBoundingClientRect()` is required to set the correct `transform-origin` value.