Skip to content

fix: compensate toolbar position on toggle to keep cursor under switch#164

Merged
aidenybai merged 1 commit intomainfrom
fix/toolbar-toggle-position-compensation
Feb 7, 2026
Merged

fix: compensate toolbar position on toggle to keep cursor under switch#164
aidenybai merged 1 commit intomainfrom
fix/toolbar-toggle-position-compensation

Conversation

@aidenybai
Copy link
Copy Markdown
Owner

@aidenybai aidenybai commented Feb 7, 2026

Summary

  • When toggling the toolbar enabled/disabled, the expandable buttons (Select, Comment) appear or disappear, changing the toolbar width. This caused the toggle switch to shift out from under the user's cursor.
  • Adds horizontal position compensation that offsets the toolbar by the expandable buttons width on toggle, clamped to viewport bounds. Handles all snap edges correctly — left edge skips compensation (anchored to margin), right/top/bottom edges apply it.
  • Includes deferred width learning for toolbars that mount disabled (buttons hidden in grid-cols-[0fr]), measuring after the first enable animation completes.

Test plan

  • Drag toolbar to bottom edge, toggle on/off — cursor stays under switch
  • Drag toolbar to right edge, toggle on/off — no extra whitespace, toolbar stays flush
  • Drag toolbar to left edge, toggle on/off — no jank, toolbar stays anchored to left margin
  • Drag toolbar to top edge, toggle on/off — cursor stays under switch
  • Rapid toggle (spam click) — no spazzing or visual glitches
  • Refresh page with toolbar disabled, then enable — buttons appear and subsequent toggles compensate correctly

Made with Cursor


Note

Medium Risk
Touches core toolbar positioning/state persistence and introduces timing-based width measurement and animation gating, which could cause edge-case layout/state desync across snap edges or rapid toggles.

Overview
Fixes toolbar jank when toggling enabled by compensating the toolbar’s x position for the width of the expandable buttons (Select/Comment), clamping to viewport bounds and skipping compensation on the left snap edge.

Adds toggle-specific animation state/timeouts (isToggleAnimating, toggleAnimationTimeout) to prevent external state sync from fighting in-progress toggle/collapse animations, and introduces a measured expandableButtonsRef/lastKnownExpandableWidth with a deferred “learn width after first enable” fallback when mounting disabled.

Written by Cursor Bugbot for commit ed3c642. This will update automatically on new commits. Configure here.


Summary by cubic

Keeps the toolbar toggle switch under the cursor by compensating for width changes when expandable buttons appear/disappear. Adds edge-aware horizontal offset with viewport clamping for smooth, stable toggles.

  • Bug Fixes
    • Offsets toolbar by the expandable buttons width on toggle; skips when snapped to the left edge, applies for right/top/bottom.
    • Clamps position within viewport margins to stay flush and avoid whitespace.
    • Measures expandable width via a new ref; defers measurement for toolbars that mount disabled until after the first enable animation.
    • Adds a toggle animation state to pause external updates and smooth transitions; clears timeouts on cleanup and persists the new position ratio/dimensions after compensation.

Written for commit ed3c642. Summary will update on new commits.

When toggling the toolbar's enabled state, the expandable buttons cause a
width change that shifts the toggle switch out from under the cursor. This
adds position compensation that offsets the toolbar horizontally by the
expandable buttons width, clamped to viewport bounds.

Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Feb 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-grab-website Ready Ready Preview, Comment Feb 7, 2026 7:50am

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium Urgency - Implementation is mostly solid, but there's a critical race condition in rapid toggling and several edge cases that could cause incorrect positioning.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow runpullfrog.com𝕏

Comment on lines +445 to +450
const handleToggleEnabled = createDragAwareHandler(() => {
const isCurrentlyEnabled = Boolean(props.enabled);
const edge = snapEdge();
const preTogglePosition = position();
const expandableWidth = lastKnownExpandableWidth;
const shouldCompensatePosition = expandableWidth > 0 && edge !== "left";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition with rapid toggling: The logic reads lastKnownExpandableWidth synchronously, but if the user rapidly toggles, the previous timeout (lines 500-509) might not have completed yet, meaning lastKnownExpandableWidth could still be 0 on subsequent toggles after the first enable. This would cause no compensation on the second toggle.

Consider setting lastKnownExpandableWidth synchronously during the first enable if expandableButtonsRef is available, rather than deferring it to the timeout.

Comment on lines +458 to +464
if (expandableWidth > 0) {
const widthChange = isCurrentlyEnabled ? -expandableWidth : expandableWidth;
expandedDimensions = {
width: expandedDimensions.width + widthChange,
height: expandedDimensions.height,
};
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing synchronization: expandedDimensions is mutated here, but this happens before the DOM has actually changed (the toggle happens at line 456). When toggling from enabled→disabled, you're subtracting expandableWidth, but the toolbar is still visually expanded until the CSS animation completes.

This creates a mismatch: expandedDimensions.width no longer reflects the actual visual width during the animation. If a resize event or state change callback fires during the animation, calculations using expandedDimensions will be incorrect.

Comment on lines +466 to +475
if (shouldCompensatePosition) {
const viewport = getVisualViewport();
const positionOffset = isCurrentlyEnabled ? expandableWidth : -expandableWidth;
const clampMin = viewport.offsetLeft + TOOLBAR_SNAP_MARGIN_PX;
const clampMax = viewport.offsetLeft + viewport.width - expandedDimensions.width - TOOLBAR_SNAP_MARGIN_PX;
const compensatedX = clampToViewport(
preTogglePosition.x + positionOffset,
clampMin,
clampMax,
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using updated dimensions for clamping before they're accurate: You calculate clampMax using the already-mutated expandedDimensions.width (line 470), but this assumes the toolbar has already changed size. During the toggle animation, the toolbar hasn't resized yet, so clamping against the future dimensions could cause the toolbar to shift incorrectly.

For disabling (going from wide→narrow), you should use the old width for initial clamping, then let the position settle after the animation. For enabling (narrow→wide), you need the new width, but it's not yet rendered.

Comment on lines +480 to +496
toggleAnimationTimeout = setTimeout(() => {
setIsToggleAnimating(false);
const newRatio = getRatioFromPosition(
edge,
position().x,
position().y,
expandedDimensions.width,
expandedDimensions.height,
);
setPositionRatio(newRatio);
saveAndNotify({
edge,
ratio: newRatio,
collapsed: isCollapsed(),
enabled: !isCurrentlyEnabled,
});
}, TOOLBAR_COLLAPSE_ANIMATION_DURATION_MS);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Position recalculation uses stale position signal: Line 484 reads position().x and position().y inside the timeout callback. However, the user could have dragged the toolbar, or a resize could have occurred during the 150ms animation. This would cause the ratio calculation to be based on wherever the toolbar ended up, not where the compensation placed it.

Consider capturing the compensated position in a variable and using that for the ratio calculation, rather than reading from the signal.

Comment on lines +886 to +888
if (props.enabled && expandableButtonsRef) {
lastKnownExpandableWidth = expandableButtonsRef.offsetWidth;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measuring expandable width on mount when disabled: If the toolbar mounts with props.enabled === false, this block won't execute (line 886 condition fails). But you correctly handle this later in the deferred width learning (lines 497-510). However, there's a subtle issue: if the toolbar mounts enabled, you measure immediately here, but what if expandableButtonsRef hasn't been assigned yet?

SolidJS ref assignment happens during rendering, but onMount runs after the component returns. There's no guarantee expandableButtonsRef is defined at this point. Consider using createEffect or adding a null check.

const unsubscribe = props.onSubscribeToStateChanges(
(state: ToolbarState) => {
if (isCollapseAnimating()) return;
if (isCollapseAnimating() || isToggleAnimating()) return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking state change callbacks during toggle animation: This guards against external state changes during animations, which is good. However, this means if another toolbar instance (or the same toolbar in another browser tab) toggles enabled/disabled, this toolbar won't sync until the animation completes. This could cause a brief desync in multi-instance scenarios.

Not necessarily a bug, but worth considering if this is the intended behavior.

>
Select
</Tooltip>
<div ref={expandableButtonsRef} class="flex items-center">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ref assignment to wrapper div: The ref is assigned to a wrapper div containing both expandable buttons. This works, but you're measuring the combined width. If there's any gap, margin, or padding between the two buttons at the wrapper level (the flex items-center at line 1077), you'll measure more than just the buttons themselves.

Looking at the code, this seems intentional and correct since both buttons are always shown/hidden together. But verify there's no extra spacing that would throw off measurements.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Feb 7, 2026

Open in StackBlitz

@react-grab/cli

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/cli@164

grab

npm i https://pkg.pr.new/aidenybai/react-grab/grab@164

@react-grab/ami

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/ami@164

@react-grab/amp

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/amp@164

@react-grab/claude-code

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/claude-code@164

@react-grab/codex

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/codex@164

@react-grab/cursor

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/cursor@164

@react-grab/droid

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/droid@164

@react-grab/gemini

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/gemini@164

@react-grab/opencode

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/opencode@164

react-grab

npm i https://pkg.pr.new/aidenybai/react-grab@164

@react-grab/relay

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/relay@164

@react-grab/utils

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/utils@164

commit: ed3c642

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 1 file

@aidenybai aidenybai merged commit 36e046c into main Feb 7, 2026
13 of 14 checks passed
divyanshudhruv pushed a commit to divyanshudhruv/react-grab that referenced this pull request Mar 4, 2026
aidenybai#164)

When toggling the toolbar's enabled state, the expandable buttons cause a
width change that shifts the toggle switch out from under the cursor. This
adds position compensation that offsets the toolbar horizontally by the
expandable buttons width, clamped to viewport bounds.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant