How to Implement Transform-Origin for Popovers Correctly

Popovers should animate from their trigger element's position using the CSS custom property transform-origin: var(--transform-origin) instead of the default center value to maintain spatial consistency and improve perceived performance.

According to the animation standards in the emilkowalski/skills repository, anchoring popover animations to their trigger points creates a clearer mental model for users and eliminates the perception that content is appearing from thin air. This approach leverages CSS custom properties to dynamically calculate the origin point based on the trigger's location in the viewport.

Why Transform-Origin Matters for Popovers

The default CSS rule transform-origin: center causes popovers to scale from their geometric center, making them appear to grow out of thin air rather than emanating from the button or control that triggered them. This breaks spatial consistency and harms perceived performance.

Spatial consistency is maintained when the animation originates from the trigger because it reinforces the visual relationship between the control and its resulting popover. Users immediately understand that the dropdown menu, tooltip, or popover panel belongs to the specific button they interacted with.

Perceived speed improves because an origin-aware animation feels more responsive. When the motion starts directly under the user's focus point (the cursor or tapped element), the interaction feels faster than a center-scaled animation that draws the eye away from the trigger.

The Implementation Pattern

CSS Foundation

The base implementation relies on a CSS custom property --transform-origin that inherits its value from the trigger element. In skills/review-animations/STANDARDS.md (lines 54-56), the recommended pattern is codified as:

.popover {
  /* The variable is set by the trigger element */
  transform-origin: var(--transform-origin);
}

/* Example trigger setting the variable */
.button {
  --transform-origin: left top;
}

This pattern ensures that every popover in your system inherits the correct origin automatically, provided the trigger sets the variable appropriately.

Dynamic Origin Calculation

For precise positioning that accounts for the trigger's actual location in the viewport, calculate the origin dynamically using JavaScript or React. As documented in skills/emil-design-eng/SKILL.md (lines 48-49), the calculation should target the center of the trigger element:

import { useRef, useEffect } from 'react';

function Popover({ anchorRef, children }) {
  const popRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!anchorRef.current || !popRef.current) return;
    
    const rect = anchorRef.current.getBoundingClientRect();
    const origin = `${rect.left + rect.width / 2}px ${rect.top + rect.height / 2}px`;
    
    popRef.current.style.setProperty('--transform-origin', origin);
  }, [anchorRef]);

  return (
    <div ref={popRef} className="popover">
      {children}
    </div>
  );
}

This approach sets the --transform-origin custom property to the exact center of the anchor element, ensuring the popover scales from that specific point regardless of scroll position or layout changes.

Exception Handling for Modals

Not all floating UI elements should follow the trigger-based origin rule. Modals are the primary exception because they are not anchored to a specific trigger element; they overlay the entire viewport and should remain centered.

As noted in skills/improve-animations/AUDIT.md (lines 53-57), modals should retain the default center-based transformation:

.modal {
  /* Modals are centered, so keep the default origin */
  transform-origin: center;
}

This distinction is critical: popovers, tooltips, and dropdown menus scale from their trigger, while dialogs and modal overlays scale from the viewport center.

Standards and Source Files in emilkowalski/skills

The guidance for transform-origin implementation is codified across multiple files in the repository:

Summary

  • Use transform-origin: var(--transform-origin) for all popover components to anchor animations to their trigger elements.
  • Set the --transform-origin custom property dynamically based on the trigger's bounding rectangle, typically targeting its center point.
  • Keep transform-origin: center only for true modals and dialogs that are not anchored to specific trigger elements.
  • Reference the standards in skills/review-animations/STANDARDS.md and skills/emil-design-eng/SKILL.md to ensure implementation consistency across your codebase.

Frequently Asked Questions

What is the correct transform-origin for popover animations?

The correct transform-origin for popovers is var(--transform-origin), a CSS custom property that should be set to the trigger element's position. This replaces the default center value and ensures the popover appears to grow from the button or control that activated it, rather than from its own geometric center.

Why shouldn't popovers use transform-origin: center?

Using transform-origin: center breaks spatial consistency by making the popover appear to emerge from nowhere rather than from the trigger element. This creates a disconnect between the user's action and the system's response, reducing perceived performance and making the interface feel less responsive and intuitive.

How do I calculate the transform-origin position dynamically?

Calculate the origin by retrieving the trigger element's bounding rectangle using getBoundingClientRect(), then computing the center point: ${rect.left + rect.width / 2}px ${rect.top + rect.height / 2}px. Set this value as the --transform-origin custom property on the popover element using element.style.setProperty().

Are there any exceptions to the transform-origin rule for popovers?

Yes, modals are the primary exception. Unlike popovers, which are anchored to specific trigger elements, modals overlay the entire viewport and should maintain transform-origin: center. This ensures they scale from the center of the screen, which is appropriate for their centered, non-anchored nature.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →