How to Implement Origin-Aware Animations that Scale from Trigger Elements in Skilled-Based UIs
To create origin-aware animations, store the trigger element's anchor point in a CSS custom property (--transform-origin) and apply it as the transform-origin for overlay elements before the animation starts.
Origin-aware animations make popovers, tooltips, and dropdowns appear to grow directly out of the element that opened them. This pattern, documented in emilkowalski/skills, eliminates the disconnected "floating" feel of center-scaled overlays and creates a stronger spatial relationship between user actions and UI responses.
Why Default Center Scaling Breaks the Experience
Most UI overlays anchor to a specific trigger element. When they scale from their own center, the animation feels psychologically disconnected from the user's click or hover action. The skills/emil-design-eng/SKILL.md file explicitly identifies this as a design violation: popovers should scale from their trigger, not from their geometric center 【emil-design-eng/SKILL.md#L34-L42】.
The only exception is modals, which are intentionally centered in the viewport and therefore correctly use the default center origin.
The Canonical CSS Pattern
According to skills/review-animations/SKILL.md, the solution uses a single CSS custom property that JavaScript populates dynamically 【review-animations/SKILL.md#L52-L53】:
.popover {
transform-origin: var(--transform-origin);
transform: scale(0.95);
opacity: 0;
transition: transform 150ms ease-out,
opacity 150ms ease-out;
}
.popover[data-open] {
transform: scale(1);
opacity: 1;
}
This pattern satisfies the "origin & physical correctness" rule from the standards documentation in skills/review-animations/STANDARDS.md.
Calculating the Transform Origin at Runtime
The implementation requires measuring the trigger element and converting its position into CSS-compatible coordinates. Here's the complete workflow:
HTML Structure
<button id="menuBtn" class="trigger">Open Menu</button>
<div id="menuPopover" class="popover" aria-hidden="true">
<ul>
<li>Edit</li>
<li>Duplicate</li>
<li>Delete</li>
</ul>
</div>
JavaScript Measurement and Activation
const trigger = document.getElementById('menuBtn');
const popover = document.getElementById('menuPopover');
trigger.addEventListener('click', () => {
// 1. Capture trigger geometry in viewport coordinates
const rect = trigger.getBoundingClientRect();
// 2. Compute origin point (center used here; adjust corners as needed)
const originX = rect.left + rect.width / 2;
const originY = rect.top + rect.height / 2;
// 3. Inject the origin into CSS before animation starts
popover.style.setProperty('--transform-origin', `${originX}px ${originY}px`);
// 4. Position the overlay relative to trigger
popover.style.left = `${rect.left}px`;
popover.style.top = `${rect.bottom}px`;
// 5. Trigger the animation via attribute
popover.dataset.open = '';
});
Key Implementation Details
-
Timing matters: Set
--transform-originbefore adding the activation class or attribute. CSS transitions evaluate the property at animation start; changing it mid-transition creates visual glitches. -
Coordinate precision:
getBoundingClientRect()returns subpixel-accurate floats. Pass these directly to CSS—browser rounding handles the rest. -
Viewport-relative coordinates: The origin values are viewport-relative, which works correctly for absolutely positioned overlays. For fixed positioning inside transformed containers, adjust for offset ancestors.
-
Corner origins: For dropdown menus, use
rect.leftandrect.topdirectly to scale from the top-left corner instead of center:
popover.style.setProperty('--transform-origin', `${rect.left}px ${rect.top}px`);
Modals: The Deliberate Exception
Modals remain centered by design. The same skill file notes that transform-origin: center is correct for modals because they have no anchor element and appear in the viewport center 【emil-design-eng/SKILL.md#L34-L42】. Omit the custom property injection for modal implementations.
Rendering Pipeline Overview
| Step | Operation | Critical File Reference |
|---|---|---|
| Measure | getBoundingClientRect() on trigger |
Any component implementation |
| Compute | Calculate anchor point (center/corner) | Based on overlay type |
| Inject | element.style.setProperty('--transform-origin', ...) |
review-animations/SKILL.md |
| Position | Set left/top or use anchoring API |
Implementation-specific |
| Animate | Toggle data-open or activation class |
CSS transition on transform/opacity |
Summary
- Default
transform-origin: centerviolates physical correctness for anchored overlays. - The
skillsrepository mandatesvar(--transform-origin)as the canonical solution. - Calculate trigger position via
getBoundingClientRect(), inject into CSS, then animate. - Modals intentionally keep center origin; all other overlays require trigger-relative scaling.
- Set the custom property before triggering the animation to prevent visual jumps.
Frequently Asked Questions
What happens if I forget to set --transform-origin before the animation starts?
The browser falls back to the default center value, breaking the origin-aware effect. The overlay scales from its geometric center rather than the trigger point, producing the "floating" disconnected animation that the review-animations skill explicitly flags as incorrect 【review-animations/SKILL.md#L52-L53】.
Can I use this pattern with CSS @keyframes instead of transitions?
Yes. The same --transform-origin injection works with @keyframes animations. Define your keyframes without explicit transform-origin values—the variable inheritance handles the dynamic origin point. Trigger the animation by adding a class that applies animation-name.
How do I handle origin-aware animations with React/Vue/Svelte?
Store the trigger rect in component state or a ref, compute the origin string in an effect or event handler, then bind the style property directly:
<div
className="popover"
style={{
'--transform-origin': `${originX}px ${originY}px`
}}
>
The architectural pattern from skills/review-animations/STANDARDS.md applies regardless of framework.
Does this work for exit animations as well?
Exit animations require the same origin to maintain physical consistency. Preserve the --transform-origin value set during entry; do not recalculate it when closing. If the trigger has moved (scrolled viewport), the exit will scale back to the original trigger position—often desirable for maintaining the user's spatial memory of the interaction.
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 →