How to Implement Origin‑Aware Dropdown and Tooltip Animations
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, 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
- Measure the trigger using
getBoundingClientRect()to getleft,top,width, andheight. - Measure the popup after it renders (post‑positioning).
- Compute the offset between trigger center/bottom and popup position.
- Apply via CSS custom property
--originfor clean separation of concerns.
This calculation must run after layout—typically in useEffect (React) or a requestAnimationFrame callback.
React Implementation: Origin‑Aware Dropdown
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.
.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 againstscale(0)inskills/review-animations/SKILL.mdlines 31‑52.ease-outcurve — avoids the sluggish feeling ofease-in, as noted inskills/emil-design-eng/SKILL.mdlines 46‑53.- 180 ms duration — stays well under the 300 ms performance budget defined in
skills/review-animations/STANDARDS.mdline 49.
Tooltip Variant: Same Principle, Different Origin
Tooltips typically appear above or below their anchor, requiring a slightly different origin calculation.
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>
);
}
.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 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 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 file recommends handling focus management manually for dropdowns—ensure:
aria-expandedtoggles on the trigger.- Focus moves into the popup on open.
Escapecloses 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:
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>
);
}
.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-originto the trigger's position, making popups feel physically anchored. - Never animate from
scale(0)— start from0.95perskills/review-animations/SKILL.md. - Use
ease-outeasing and keep duration under 300 ms (ideally 150‑180 ms) perskills/emil-design-eng/SKILL.mdandSTANDARDS.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.
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 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 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.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →