Skip to content

Conversation

@adbjo
Copy link

@adbjo adbjo commented Oct 24, 2025

📝 Description

  • Make tab cursor shrink / grow along with selected tab
  • Do not render default size / position
  • Do not animate initial size / position

⛳️ Current behavior (updates)

  • Tab cursor does not shrink / grow along with selected tab (stays the same size until state update)
  • Renders default size before getting sized to match selected tab
  • initialization is animated (from default size to selected tab size)

🚀 New behavior

See description :)

💣 Is this a breaking change (Yes/No):

No

📝 Additional Information

Summary by CodeRabbit

  • Bug Fixes

    • Cursor reliably repositions when the selected tab changes, when switching tab variants or orientation, and after layout or content changes.
  • Performance

    • More responsive cursor updates with reduced layout jank via optimized repositioning and resize observation.
  • UX

    • Cursor stays hidden until fully initialized; transitions start only after readiness to prevent flicker and improve visual stability.
  • Refactor

    • Rendering and update flow streamlined to ensure consistent cursor behavior across layouts.

@adbjo adbjo requested a review from jrgarciadev as a code owner October 24, 2025 20:17
@changeset-bot
Copy link

changeset-bot bot commented Oct 24, 2025

🦋 Changeset detected

Latest commit: fc34267

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@heroui/tabs Patch
@heroui/theme Patch
@heroui/react Patch
@heroui/autocomplete Patch
@heroui/checkbox Patch
@heroui/date-input Patch
@heroui/date-picker Patch
@heroui/form Patch
@heroui/input-otp Patch
@heroui/input Patch
@heroui/number-input Patch
@heroui/radio Patch
@heroui/select Patch
@heroui/table Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link

vercel bot commented Oct 24, 2025

@adbjo is attempting to deploy a commit to the HeroUI Inc Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 24, 2025

Walkthrough

Reworks Tabs cursor to a ref-driven implementation: adds cursorRef, memoized getCursorStyles/updateCursorPosition, a ResizeObserver to track the selected tab, and data-[initialized]/data-[animated] gating so CSS transitions run only after cursor placement. No public API/signature changes.

Changes

Cohort / File(s) Change Summary
Tabs component (cursor logic)
packages/components/tabs/src/tabs.tsx
Added cursorRef; added previousVariant and previousIsVertical refs; replaced inline cursor styling with memoized getCursorStyles and useCallback updateCursorPosition; introduced withAnimationReset for animation gating; attached ResizeObserver to the selected tab; effect to observe/cleanup; render now conditionally mounts cursor span and attaches cursorRef; uses data-[initialized]/data-[animated].
Theme cursor transitions
packages/core/theme/src/components/tabs.ts
Removed unconditional cursor transitions; cursor invisible by default; data-[initialized=true] makes it visible; data-[animated=true] enables transitions on left/top/width/height with specified duration/easing.
Changeset
.changeset/serious-eels-stare.md
Added patch changeset for @heroui/tabs and @heroui/theme with description "responsive tab cursor."

Sequence Diagram(s)

sequenceDiagram
    participant Tabs as Tabs component
    participant TabEl as Selected tab element (DOM)
    participant Cursor as cursorRef (span)
    participant RO as ResizeObserver
    participant rAF as requestAnimationFrame

    Tabs->>Cursor: conditionally render & attach ref
    Tabs->>TabEl: query selected tab element
    Tabs->>RO: observe TabEl (on mount/selection)
    Note over TabEl,RO: layout or selection changes
    RO->>Tabs: notify resize
    Tabs->>Tabs: updateCursorPosition(selectedTab)
    Tabs->>TabEl: getBoundingClientRect()
    Tabs->>Tabs: getCursorStyles(tabRect, variant, isVertical)
    Tabs->>Cursor: set inline left/top/width/height
    Tabs->>rAF: schedule setting data-[initialized]=true / data-[animated]
    rAF->>Cursor: set data attributes -> enable CSS transitions
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Potential focus areas:

  • ResizeObserver lifecycle and cleanup in the effect
  • Geometry calculations for "underlined" vs other variants and vertical mode
  • useCallback/useEffect dependency correctness to avoid stale closures or extra updates
  • rAF timing and interaction between data-[initialized] / data-[animated] and CSS transitions

Possibly related PRs

Suggested reviewers

  • jrgarciadev
  • wingkwong

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: making the tab cursor responsive to resize and match the selected tab, which aligns with the core objective of the PR.
Description check ✅ Passed The description follows the template structure with all major sections completed: clear description of changes, current behavior issues addressed, new behavior outlined, and breaking change status specified.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (4)
packages/components/tabs/src/tabs.tsx (4)

53-72: Consider making magic numbers configurable or documented.

The hardcoded values for the underlined variant (10% left offset, 80% width, 2px bottom offset) work but could be fragile if the design evolves. Consider extracting these as constants with descriptive names or making them configurable through props/theme.

Example refactor:

+const UNDERLINED_CURSOR_INSET = 0.1; // 10% horizontal inset
+const UNDERLINED_CURSOR_BOTTOM_OFFSET = 2; // px from bottom
+
 const getCursorStyles = useCallback(
   (tabRect: DOMRect) => {
     if (variant === "underlined") {
       return {
-        left: `${tabRect.left + tabRect.width * 0.1}px`,
-        top: `${tabRect.top + tabRect.height - 2}px`,
-        width: `${tabRect.width * 0.8}px`,
+        left: `${tabRect.left + tabRect.width * UNDERLINED_CURSOR_INSET}px`,
+        top: `${tabRect.top + tabRect.height - UNDERLINED_CURSOR_BOTTOM_OFFSET}px`,
+        width: `${tabRect.width * (1 - 2 * UNDERLINED_CURSOR_INSET)}px`,
         height: "",
       };
     }

84-89: Type casting to DOMRect is misleading.

The manually constructed object is missing many DOMRect properties (x, y, right, bottom, toJSON, etc.). While it works because getCursorStyles only uses the provided properties, the as DOMRect cast is technically incorrect and could be confusing.

Consider using a more accurate type:

+  type TabRect = Pick<DOMRect, 'width' | 'height' | 'left' | 'top'>;
+  
   const tabRect = {
     width: selectedTab.offsetWidth,
     height: selectedTab.offsetHeight,
     left: selectedTab.offsetLeft,
     top: selectedTab.offsetTop,
-  } as DOMRect;
+  } as TabRect;

And update getCursorStyles signature:

-const getCursorStyles = useCallback(
-  (tabRect: DOMRect) => {
+const getCursorStyles = useCallback(
+  (tabRect: TabRect) => {

98-98: Add explanatory comment for requestAnimationFrame usage.

The requestAnimationFrame ensures the data-initialized attribute is set after the initial styles are applied, preventing unwanted animation on first render. This pattern could benefit from a brief comment for future maintainers.

+  // Set initialized flag after paint to prevent animating from default position
   requestAnimationFrame(() => cursorRef.current?.setAttribute("data-initialized", "true"));

108-112: Consider reusing ResizeObserver for better performance.

Creating a new ResizeObserver instance on every selectedKey change works but could be optimized. Consider maintaining a single observer and updating what it observes, or verify if the current approach causes any performance issues with rapid tab switching.

Example optimization (if needed):

useEffect(() => {
  const observer = new ResizeObserver(updateCursorPosition);
  
  return () => observer.disconnect();
}, [updateCursorPosition]);

useEffect(() => {
  const selectedTab = domRef.current?.querySelector(`[data-key="${selectedKey}"]`);
  if (!selectedTab || !observerRef.current) return;
  
  observerRef.current.disconnect();
  observerRef.current.observe(selectedTab);
}, [domRef, selectedKey]);
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 328c57d and 1d99d04.

📒 Files selected for processing (2)
  • .changeset/swift-kiwis-knock.md (1 hunks)
  • packages/components/tabs/src/tabs.tsx (3 hunks)
🔇 Additional comments (5)
.changeset/swift-kiwis-knock.md (1)

1-5: LGTM!

The changeset correctly documents a minor version bump with an appropriate description that aligns with the feature enhancement.

packages/components/tabs/src/tabs.tsx (4)

4-4: LGTM!

The additional React hooks (useEffect, useCallback) are appropriately imported for the new cursor positioning logic.


47-47: LGTM!

The cursorRef is properly declared to manage the cursor element.


121-127: Excellent solution for preventing initial animation flash!

The className logic using [&:not([data-initialized])] modifiers effectively hides the cursor and disables transitions until it's properly positioned, preventing the visual issue of animating from a default position on initialization. This directly addresses one of the key objectives mentioned in the PR description.


103-113: No action required—data-key attribute is reliably set on Tab components.

The data-key attribute used in the selector is explicitly set in the Tab component (packages/components/tabs/src/tab.tsx: data-key={key}), confirming the querySelector will work correctly. The useEffect hook dependencies and ResizeObserver cleanup logic are sound.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Oct 25, 2025

Open in StackBlitz

@heroui/accordion

npm i https://pkg.pr.new/@heroui/accordion@5846

@heroui/alert

npm i https://pkg.pr.new/@heroui/alert@5846

@heroui/autocomplete

npm i https://pkg.pr.new/@heroui/autocomplete@5846

@heroui/avatar

npm i https://pkg.pr.new/@heroui/avatar@5846

@heroui/badge

npm i https://pkg.pr.new/@heroui/badge@5846

@heroui/breadcrumbs

npm i https://pkg.pr.new/@heroui/breadcrumbs@5846

@heroui/button

npm i https://pkg.pr.new/@heroui/button@5846

@heroui/calendar

npm i https://pkg.pr.new/@heroui/calendar@5846

@heroui/card

npm i https://pkg.pr.new/@heroui/card@5846

@heroui/checkbox

npm i https://pkg.pr.new/@heroui/checkbox@5846

@heroui/chip

npm i https://pkg.pr.new/@heroui/chip@5846

@heroui/code

npm i https://pkg.pr.new/@heroui/code@5846

@heroui/date-input

npm i https://pkg.pr.new/@heroui/date-input@5846

@heroui/date-picker

npm i https://pkg.pr.new/@heroui/date-picker@5846

@heroui/divider

npm i https://pkg.pr.new/@heroui/divider@5846

@heroui/drawer

npm i https://pkg.pr.new/@heroui/drawer@5846

@heroui/dropdown

npm i https://pkg.pr.new/@heroui/dropdown@5846

@heroui/form

npm i https://pkg.pr.new/@heroui/form@5846

@heroui/image

npm i https://pkg.pr.new/@heroui/image@5846

@heroui/input

npm i https://pkg.pr.new/@heroui/input@5846

@heroui/input-otp

npm i https://pkg.pr.new/@heroui/input-otp@5846

@heroui/kbd

npm i https://pkg.pr.new/@heroui/kbd@5846

@heroui/link

npm i https://pkg.pr.new/@heroui/link@5846

@heroui/listbox

npm i https://pkg.pr.new/@heroui/listbox@5846

@heroui/menu

npm i https://pkg.pr.new/@heroui/menu@5846

@heroui/modal

npm i https://pkg.pr.new/@heroui/modal@5846

@heroui/navbar

npm i https://pkg.pr.new/@heroui/navbar@5846

@heroui/number-input

npm i https://pkg.pr.new/@heroui/number-input@5846

@heroui/pagination

npm i https://pkg.pr.new/@heroui/pagination@5846

@heroui/popover

npm i https://pkg.pr.new/@heroui/popover@5846

@heroui/progress

npm i https://pkg.pr.new/@heroui/progress@5846

@heroui/radio

npm i https://pkg.pr.new/@heroui/radio@5846

@heroui/ripple

npm i https://pkg.pr.new/@heroui/ripple@5846

@heroui/scroll-shadow

npm i https://pkg.pr.new/@heroui/scroll-shadow@5846

@heroui/select

npm i https://pkg.pr.new/@heroui/select@5846

@heroui/skeleton

npm i https://pkg.pr.new/@heroui/skeleton@5846

@heroui/slider

npm i https://pkg.pr.new/@heroui/slider@5846

@heroui/snippet

npm i https://pkg.pr.new/@heroui/snippet@5846

@heroui/spacer

npm i https://pkg.pr.new/@heroui/spacer@5846

@heroui/spinner

npm i https://pkg.pr.new/@heroui/spinner@5846

@heroui/switch

npm i https://pkg.pr.new/@heroui/switch@5846

@heroui/table

npm i https://pkg.pr.new/@heroui/table@5846

@heroui/tabs

npm i https://pkg.pr.new/@heroui/tabs@5846

@heroui/toast

npm i https://pkg.pr.new/@heroui/toast@5846

@heroui/tooltip

npm i https://pkg.pr.new/@heroui/tooltip@5846

@heroui/user

npm i https://pkg.pr.new/@heroui/user@5846

@heroui/react

npm i https://pkg.pr.new/@heroui/react@5846

@heroui/system

npm i https://pkg.pr.new/@heroui/system@5846

@heroui/system-rsc

npm i https://pkg.pr.new/@heroui/system-rsc@5846

@heroui/theme

npm i https://pkg.pr.new/@heroui/theme@5846

@heroui/use-aria-accordion

npm i https://pkg.pr.new/@heroui/use-aria-accordion@5846

@heroui/use-aria-accordion-item

npm i https://pkg.pr.new/@heroui/use-aria-accordion-item@5846

@heroui/use-aria-button

npm i https://pkg.pr.new/@heroui/use-aria-button@5846

@heroui/use-aria-link

npm i https://pkg.pr.new/@heroui/use-aria-link@5846

@heroui/use-aria-modal-overlay

npm i https://pkg.pr.new/@heroui/use-aria-modal-overlay@5846

@heroui/use-aria-multiselect

npm i https://pkg.pr.new/@heroui/use-aria-multiselect@5846

@heroui/use-aria-overlay

npm i https://pkg.pr.new/@heroui/use-aria-overlay@5846

@heroui/use-callback-ref

npm i https://pkg.pr.new/@heroui/use-callback-ref@5846

@heroui/use-clipboard

npm i https://pkg.pr.new/@heroui/use-clipboard@5846

@heroui/use-data-scroll-overflow

npm i https://pkg.pr.new/@heroui/use-data-scroll-overflow@5846

@heroui/use-disclosure

npm i https://pkg.pr.new/@heroui/use-disclosure@5846

@heroui/use-draggable

npm i https://pkg.pr.new/@heroui/use-draggable@5846

@heroui/use-form-reset

npm i https://pkg.pr.new/@heroui/use-form-reset@5846

@heroui/use-image

npm i https://pkg.pr.new/@heroui/use-image@5846

@heroui/use-infinite-scroll

npm i https://pkg.pr.new/@heroui/use-infinite-scroll@5846

@heroui/use-intersection-observer

npm i https://pkg.pr.new/@heroui/use-intersection-observer@5846

@heroui/use-is-mobile

npm i https://pkg.pr.new/@heroui/use-is-mobile@5846

@heroui/use-is-mounted

npm i https://pkg.pr.new/@heroui/use-is-mounted@5846

@heroui/use-measure

npm i https://pkg.pr.new/@heroui/use-measure@5846

@heroui/use-pagination

npm i https://pkg.pr.new/@heroui/use-pagination@5846

@heroui/use-real-shape

npm i https://pkg.pr.new/@heroui/use-real-shape@5846

@heroui/use-ref-state

npm i https://pkg.pr.new/@heroui/use-ref-state@5846

@heroui/use-resize

npm i https://pkg.pr.new/@heroui/use-resize@5846

@heroui/use-safe-layout-effect

npm i https://pkg.pr.new/@heroui/use-safe-layout-effect@5846

@heroui/use-scroll-position

npm i https://pkg.pr.new/@heroui/use-scroll-position@5846

@heroui/use-ssr

npm i https://pkg.pr.new/@heroui/use-ssr@5846

@heroui/use-theme

npm i https://pkg.pr.new/@heroui/use-theme@5846

@heroui/use-update-effect

npm i https://pkg.pr.new/@heroui/use-update-effect@5846

@heroui/use-viewport-size

npm i https://pkg.pr.new/@heroui/use-viewport-size@5846

@heroui/aria-utils

npm i https://pkg.pr.new/@heroui/aria-utils@5846

@heroui/dom-animation

npm i https://pkg.pr.new/@heroui/dom-animation@5846

@heroui/framer-utils

npm i https://pkg.pr.new/@heroui/framer-utils@5846

@heroui/react-rsc-utils

npm i https://pkg.pr.new/@heroui/react-rsc-utils@5846

@heroui/react-utils

npm i https://pkg.pr.new/@heroui/react-utils@5846

@heroui/shared-icons

npm i https://pkg.pr.new/@heroui/shared-icons@5846

@heroui/shared-utils

npm i https://pkg.pr.new/@heroui/shared-utils@5846

@heroui/stories-utils

npm i https://pkg.pr.new/@heroui/stories-utils@5846

@heroui/test-utils

npm i https://pkg.pr.new/@heroui/test-utils@5846

commit: fc34267

Copy link
Member

@wingkwong wingkwong left a comment

Choose a reason for hiding this comment

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

please also check coderabbitai comments.

@adbjo adbjo requested a review from wingkwong October 25, 2025 17:16
@adbjo
Copy link
Author

adbjo commented Oct 27, 2025

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 27, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@vercel
Copy link

vercel bot commented Oct 27, 2025

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

Project Deployment Preview Comments Updated (UTC)
heroui Ready Ready Preview Comment Nov 15, 2025 2:03pm
heroui-sb Ready Ready Preview Comment Nov 15, 2025 2:03pm

Copy link
Member

@wingkwong wingkwong left a comment

Choose a reason for hiding this comment

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

  1. Please share before & after videos for demonstration.
  2. In Vertical storybook, if you change the variant from solid to underlined, there is slight transition which is not expected. (you may compare with the production storybook).

@adbjo
Copy link
Author

adbjo commented Oct 27, 2025

  1. Please share before & after videos for demonstration.
  2. In Vertical storybook, if you change the variant from solid to underlined, there is slight transition which is not expected. (you may compare with the production storybook).

Before:
Screencast from 2025-10-27 18-55-28.webm

After:
Screencast from 2025-10-27 18-57-00.webm

  1. Fixed! I assume you meant disabling animation when changing variant

@adbjo adbjo requested a review from wingkwong October 27, 2025 19:11
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
packages/components/tabs/src/tabs.tsx (1)

80-85: Consider defining a proper type for tab rectangle.

The object literal on lines 80-85 only has four properties but is cast as DOMRect. This type assertion bypasses type checking, since DOMRect includes many additional properties (x, y, right, bottom, toJSON, etc.). While this works because getCursorStyles only accesses these four properties, it's a type safety smell.

Consider defining a custom type:

+type TabRect = {
+  width: number;
+  height: number;
+  left: number;
+  top: number;
+};
+
-const getCursorStyles = useCallback(
-  (tabRect: DOMRect) => {
+const getCursorStyles = useCallback(
+  (tabRect: TabRect) => {
    // ...
  },
  [variant],
);

// ...

const updateCursorPosition = useCallback(
  (selectedTab: HTMLElement) => {
    if (!cursorRef.current) return;

    const tabRect = {
      width: selectedTab.offsetWidth,
      height: selectedTab.offsetHeight,
      left: selectedTab.offsetLeft,
      top: selectedTab.offsetTop,
-   } as DOMRect;
+   };
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c3783d4 and 3b7471a.

📒 Files selected for processing (1)
  • packages/components/tabs/src/tabs.tsx (3 hunks)
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: adbjo
PR: heroui-inc/heroui#5846
File: packages/components/tabs/src/tabs.tsx:155-155
Timestamp: 2025-10-25T17:11:59.319Z
Learning: In packages/components/tabs/src/tabs.tsx, the renderTabs useMemo dependency array intentionally includes both `domRef` and `cursorRef` to maintain consistency in how ref objects are handled in dependency arrays, even though ref objects have stable identity across renders.
📚 Learning: 2025-10-25T17:11:59.319Z
Learnt from: adbjo
PR: heroui-inc/heroui#5846
File: packages/components/tabs/src/tabs.tsx:155-155
Timestamp: 2025-10-25T17:11:59.319Z
Learning: In packages/components/tabs/src/tabs.tsx, the renderTabs useMemo dependency array intentionally includes both `domRef` and `cursorRef` to maintain consistency in how ref objects are handled in dependency arrays, even though ref objects have stable identity across renders.

Applied to files:

  • packages/components/tabs/src/tabs.tsx
📚 Learning: 2025-10-25T17:08:46.274Z
Learnt from: adbjo
PR: heroui-inc/heroui#5846
File: packages/components/tabs/src/tabs.tsx:156-157
Timestamp: 2025-10-25T17:08:46.274Z
Learning: In packages/components/tabs/src/tabs.tsx, the renderTabs useMemo dependency array intentionally includes `variant` and `isVertical` to prevent potential side-effects, even though they might appear redundant based on static analysis.

Applied to files:

  • packages/components/tabs/src/tabs.tsx
🔇 Additional comments (7)
packages/components/tabs/src/tabs.tsx (7)

4-4: LGTM!

The new imports and ref declarations are appropriate for the ref-driven cursor positioning approach.

Also applies to: 48-48, 51-53


55-74: LGTM!

The cursor positioning math is correct. For the underlined variant, the 10% offset and 80% width properly center the cursor with margins on each side.


103-113: LGTM!

The contentRect check correctly guards against positioning the cursor for tabs that haven't rendered content yet. Based on learnings, this approach was intentionally chosen to handle tabs with only padding/borders but no content.


115-125: LGTM!

The effect correctly wires the ResizeObserver to the selected tab, with proper cleanup to prevent memory leaks. When selectedKey changes, the observer is recreated for the new tab, and the initial observation fires immediately to position the cursor.


133-133: LGTM!

The cursorRef is correctly attached to the cursor span, enabling direct style updates without triggering React re-renders.


167-167: Dependency array consistency maintained.

As per learnings, cursorRef is intentionally included to maintain consistency with how domRef is handled in dependency arrays, even though ref objects have stable identity across renders.


168-169: Dependency array safety maintained.

As per learnings, variant and isVertical are intentionally included to prevent potential side-effects, even though static analysis might suggest they're redundant.

@adbjo adbjo force-pushed the fix/tabs_resize_cursor_with_selected_tab branch from 879c3ac to 8f16d30 Compare October 27, 2025 20:30
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 879c3ac and 8f16d30.

📒 Files selected for processing (1)
  • packages/components/tabs/src/tabs.tsx (3 hunks)
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: adbjo
PR: heroui-inc/heroui#5846
File: packages/components/tabs/src/tabs.tsx:155-155
Timestamp: 2025-10-25T17:11:59.319Z
Learning: In packages/components/tabs/src/tabs.tsx, the renderTabs useMemo dependency array intentionally includes both `domRef` and `cursorRef` to maintain consistency in how ref objects are handled in dependency arrays, even though ref objects have stable identity across renders.
📚 Learning: 2025-10-25T17:11:59.319Z
Learnt from: adbjo
PR: heroui-inc/heroui#5846
File: packages/components/tabs/src/tabs.tsx:155-155
Timestamp: 2025-10-25T17:11:59.319Z
Learning: In packages/components/tabs/src/tabs.tsx, the renderTabs useMemo dependency array intentionally includes both `domRef` and `cursorRef` to maintain consistency in how ref objects are handled in dependency arrays, even though ref objects have stable identity across renders.

Applied to files:

  • packages/components/tabs/src/tabs.tsx
📚 Learning: 2025-10-25T17:08:46.274Z
Learnt from: adbjo
PR: heroui-inc/heroui#5846
File: packages/components/tabs/src/tabs.tsx:156-157
Timestamp: 2025-10-25T17:08:46.274Z
Learning: In packages/components/tabs/src/tabs.tsx, the renderTabs useMemo dependency array intentionally includes `variant` and `isVertical` to prevent potential side-effects, even though they might appear redundant based on static analysis.

Applied to files:

  • packages/components/tabs/src/tabs.tsx
🔇 Additional comments (7)
packages/components/tabs/src/tabs.tsx (7)

4-4: LGTM!

The added imports are necessary for the new cursor positioning implementation.


48-53: LGTM!

The ref declarations and state extractions are clean and necessary for the cursor positioning logic.


55-74: LGTM!

The cursor style calculation correctly handles both underlined and other variants, with appropriate memoization.


103-113: LGTM!

The ResizeObserver callback correctly handles tab content resizing and is properly memoized.


133-133: LGTM!

Correctly attaches the cursor ref to enable the new positioning logic.


167-169: LGTM!

The dependency array correctly includes cursorRef for consistency with the existing domRef pattern, as well as variant and isVertical to guard against potential side-effects.

Based on learnings


98-98: Add null check for unmount safety.

The requestAnimationFrame callback doesn't verify that cursorRef.current still exists before calling setAttribute. If the component unmounts between scheduling and execution, this could cause an error.

Apply this diff:

-      requestAnimationFrame(() => cursorRef.current?.setAttribute("data-initialized", "true"));
+      requestAnimationFrame(() => {
+        if (cursorRef.current) {
+          cursorRef.current.setAttribute("data-initialized", "true");
+        }
+      });

Likely an incorrect or invalid review comment.

@adbjo
Copy link
Author

adbjo commented Oct 28, 2025

  1. Please share before & after videos for demonstration.
  2. In Vertical storybook, if you change the variant from solid to underlined, there is slight transition which is not expected. (you may compare with the production storybook).

Before: Screencast from 2025-10-27 18-55-28.webm

After: Screencast from 2025-10-27 18-57-00.webm

  1. Fixed! I assume you meant disabling animation when changing variant

Toggling isVertical had same issue as variant, fixed it now too

@adbjo
Copy link
Author

adbjo commented Oct 30, 2025

  1. Please share before & after videos for demonstration.
  2. In Vertical storybook, if you change the variant from solid to underlined, there is slight transition which is not expected. (you may compare with the production storybook).

Before: Screencast from 2025-10-27 18-55-28.webm
After: Screencast from 2025-10-27 18-57-00.webm

  1. Fixed! I assume you meant disabling animation when changing variant

Toggling isVertical had same issue as variant, fixed it now too

Also saw that there was unwanted blinking when toggling between variants and isVertical. Added another fix for this, final final fix

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (2)
packages/components/tabs/src/tabs.tsx (2)

56-75: Consider extracting magic numbers for the underlined variant positioning.

The calculations on lines 60-62 use magic numbers (0.1, 0.8, -2) that could be more maintainable as named constants. This would make the positioning logic clearer and easier to adjust.

Example:

+  const UNDERLINED_INSET_RATIO = 0.1;
+  const UNDERLINED_WIDTH_RATIO = 0.8;
+  const UNDERLINED_VERTICAL_OFFSET = 2;
+
   const getCursorStyles = useCallback(
     (tabRect: DOMRect) => {
       if (variant === "underlined") {
         return {
-          left: `${tabRect.left + tabRect.width * 0.1}px`,
-          top: `${tabRect.top + tabRect.height - 2}px`,
-          width: `${tabRect.width * 0.8}px`,
+          left: `${tabRect.left + tabRect.width * UNDERLINED_INSET_RATIO}px`,
+          top: `${tabRect.top + tabRect.height - UNDERLINED_VERTICAL_OFFSET}px`,
+          width: `${tabRect.width * UNDERLINED_WIDTH_RATIO}px`,
           height: "",
         };
       }

93-114: Consider improving type safety for the tabRect object.

Line 102 asserts a plain object as DOMRect, which is not fully type-safe. While this works at runtime since only the four properties are used, it would be clearer to define a custom type or use a partial type.

+type TabRect = {
+  width: number;
+  height: number;
+  left: number;
+  top: number;
+};
+
 const updateCursorPosition = useCallback(
   (selectedTab: HTMLElement) => {
     if (!cursorRef.current) return;

     const tabRect = {
       width: selectedTab.offsetWidth,
       height: selectedTab.offsetHeight,
       left: selectedTab.offsetLeft,
       top: selectedTab.offsetTop,
-    } as DOMRect;
+    } as TabRect;

     const styles = getCursorStyles(tabRect);

Also update the getCursorStyles signature to accept TabRect instead of DOMRect.

Based on learnings

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d2f97e0 and f314565.

📒 Files selected for processing (2)
  • packages/components/tabs/src/tabs.tsx (3 hunks)
  • packages/core/theme/src/components/tabs.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/theme/src/components/tabs.ts
🧰 Additional context used
🧠 Learnings (5)
📓 Common learnings
Learnt from: adbjo
PR: heroui-inc/heroui#5846
File: packages/components/tabs/src/tabs.tsx:76-101
Timestamp: 2025-10-27T21:48:35.272Z
Learning: In packages/components/tabs/src/tabs.tsx, the updateCursorPosition useCallback dependency array intentionally includes `cursorRef.current` to handle the case where the cursor span element is unmounted and remounted (e.g., when `disableAnimation` or `disableCursorAnimation` toggles). This ensures the callback is recreated when the ref points to a new element, triggering a dependency chain that re-establishes the ResizeObserver and initializes the new cursor element with the data-initialized attribute.
</learning]
Learnt from: adbjo
PR: heroui-inc/heroui#5846
File: packages/components/tabs/src/tabs.tsx:155-155
Timestamp: 2025-10-25T17:11:59.319Z
Learning: In packages/components/tabs/src/tabs.tsx, the renderTabs useMemo dependency array intentionally includes both `domRef` and `cursorRef` to maintain consistency in how ref objects are handled in dependency arrays, even though ref objects have stable identity across renders.
Learnt from: adbjo
PR: heroui-inc/heroui#5846
File: packages/components/tabs/src/tabs.tsx:115-125
Timestamp: 2025-10-27T21:52:33.304Z
Learning: In packages/components/tabs/src/tabs.tsx, the useEffect dependency array at line 125 intentionally uses `domRef.current` rather than `domRef` because domRef.current can change between renders (when React sets it during the commit phase), whereas domRef itself has stable identity and won't change.
📚 Learning: 2025-10-27T21:48:35.272Z
Learnt from: adbjo
PR: heroui-inc/heroui#5846
File: packages/components/tabs/src/tabs.tsx:76-101
Timestamp: 2025-10-27T21:48:35.272Z
Learning: In packages/components/tabs/src/tabs.tsx, the updateCursorPosition useCallback dependency array intentionally includes `cursorRef.current` to handle the case where the cursor span element is unmounted and remounted (e.g., when `disableAnimation` or `disableCursorAnimation` toggles). This ensures the callback is recreated when the ref points to a new element, triggering a dependency chain that re-establishes the ResizeObserver and initializes the new cursor element with the data-initialized attribute.
</learning]

Applied to files:

  • packages/components/tabs/src/tabs.tsx
📚 Learning: 2025-10-25T17:11:59.319Z
Learnt from: adbjo
PR: heroui-inc/heroui#5846
File: packages/components/tabs/src/tabs.tsx:155-155
Timestamp: 2025-10-25T17:11:59.319Z
Learning: In packages/components/tabs/src/tabs.tsx, the renderTabs useMemo dependency array intentionally includes both `domRef` and `cursorRef` to maintain consistency in how ref objects are handled in dependency arrays, even though ref objects have stable identity across renders.

Applied to files:

  • packages/components/tabs/src/tabs.tsx
📚 Learning: 2025-10-25T17:08:46.274Z
Learnt from: adbjo
PR: heroui-inc/heroui#5846
File: packages/components/tabs/src/tabs.tsx:156-157
Timestamp: 2025-10-25T17:08:46.274Z
Learning: In packages/components/tabs/src/tabs.tsx, the renderTabs useMemo dependency array intentionally includes `variant` and `isVertical` to prevent potential side-effects, even though they might appear redundant based on static analysis.

Applied to files:

  • packages/components/tabs/src/tabs.tsx
📚 Learning: 2025-10-27T21:52:33.304Z
Learnt from: adbjo
PR: heroui-inc/heroui#5846
File: packages/components/tabs/src/tabs.tsx:115-125
Timestamp: 2025-10-27T21:52:33.304Z
Learning: In packages/components/tabs/src/tabs.tsx, the useEffect dependency array at line 125 intentionally uses `domRef.current` rather than `domRef` because domRef.current can change between renders (when React sets it during the commit phase), whereas domRef itself has stable identity and won't change.

Applied to files:

  • packages/components/tabs/src/tabs.tsx
🔇 Additional comments (5)
packages/components/tabs/src/tabs.tsx (5)

4-4: LGTM!

The additional React hooks imports are necessary and correctly added for the new cursor positioning and animation logic.


47-54: LGTM!

The ref declarations and state tracking setup are well-structured. The previousVariant and previousIsVertical refs enable proper animation reset detection, and the cursor ref enables direct DOM manipulation for performant positioning.


77-91: LGTM!

The animation reset wrapper correctly prevents transition animations when variant or orientation changes. Using requestAnimationFrame to re-add the attributes ensures proper timing after layout updates.


116-138: LGTM!

The ResizeObserver implementation correctly handles cursor repositioning when the selected tab's dimensions change. The effect properly observes the currently selected tab and cleans up the observer on unmount or when the selection changes.

The contentRect check on line 121 correctly guards against positioning updates before the tab has rendered content, as discussed in previous reviews.

Based on learnings


140-184: LGTM!

The cursor span correctly receives the cursorRef for DOM manipulation, and the dependency array includes all necessary values. The conditional rendering ensures the cursor only appears when animations are enabled and a tab is selected.

Based on learnings

@adbjo adbjo force-pushed the fix/tabs_resize_cursor_with_selected_tab branch from f314565 to fc34267 Compare November 6, 2025 13:08
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
packages/components/tabs/src/tabs.tsx (1)

56-75: Consider using a proper interface for tabRect parameter.

The getCursorStyles function expects a DOMRect, but the caller (line 97-102) creates a plain object and casts it to DOMRect. While functional, this is not type-safe since the object lacks DOMRect methods like toJSON().

Consider defining a dedicated interface:

+interface TabRect {
+  width: number;
+  height: number;
+  left: number;
+  top: number;
+}
+
 const getCursorStyles = useCallback(
-  (tabRect: DOMRect) => {
+  (tabRect: TabRect) => {

Then at the call site (lines 97-102), remove the as DOMRect cast.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f314565 and fc34267.

📒 Files selected for processing (3)
  • .changeset/serious-eels-stare.md (1 hunks)
  • packages/components/tabs/src/tabs.tsx (3 hunks)
  • packages/core/theme/src/components/tabs.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • .changeset/serious-eels-stare.md
🧰 Additional context used
🧠 Learnings (5)
📓 Common learnings
Learnt from: adbjo
Repo: heroui-inc/heroui PR: 5846
File: packages/components/tabs/src/tabs.tsx:76-101
Timestamp: 2025-10-27T21:48:35.308Z
Learning: In packages/components/tabs/src/tabs.tsx, the updateCursorPosition useCallback dependency array intentionally includes `cursorRef.current` to handle the case where the cursor span element is unmounted and remounted (e.g., when `disableAnimation` or `disableCursorAnimation` toggles). This ensures the callback is recreated when the ref points to a new element, triggering a dependency chain that re-establishes the ResizeObserver and initializes the new cursor element with the data-initialized attribute.
</learning]
Learnt from: adbjo
Repo: heroui-inc/heroui PR: 5846
File: packages/components/tabs/src/tabs.tsx:155-155
Timestamp: 2025-10-25T17:11:59.338Z
Learning: In packages/components/tabs/src/tabs.tsx, the renderTabs useMemo dependency array intentionally includes both `domRef` and `cursorRef` to maintain consistency in how ref objects are handled in dependency arrays, even though ref objects have stable identity across renders.
Learnt from: adbjo
Repo: heroui-inc/heroui PR: 5846
File: packages/components/tabs/src/tabs.tsx:115-125
Timestamp: 2025-10-27T21:52:33.324Z
Learning: In packages/components/tabs/src/tabs.tsx, the useEffect dependency array at line 125 intentionally uses `domRef.current` rather than `domRef` because domRef.current can change between renders (when React sets it during the commit phase), whereas domRef itself has stable identity and won't change.
📚 Learning: 2025-10-27T21:48:35.308Z
Learnt from: adbjo
Repo: heroui-inc/heroui PR: 5846
File: packages/components/tabs/src/tabs.tsx:76-101
Timestamp: 2025-10-27T21:48:35.308Z
Learning: In packages/components/tabs/src/tabs.tsx, the updateCursorPosition useCallback dependency array intentionally includes `cursorRef.current` to handle the case where the cursor span element is unmounted and remounted (e.g., when `disableAnimation` or `disableCursorAnimation` toggles). This ensures the callback is recreated when the ref points to a new element, triggering a dependency chain that re-establishes the ResizeObserver and initializes the new cursor element with the data-initialized attribute.
</learning]

Applied to files:

  • packages/core/theme/src/components/tabs.ts
  • packages/components/tabs/src/tabs.tsx
📚 Learning: 2025-10-25T17:08:46.283Z
Learnt from: adbjo
Repo: heroui-inc/heroui PR: 5846
File: packages/components/tabs/src/tabs.tsx:156-157
Timestamp: 2025-10-25T17:08:46.283Z
Learning: In packages/components/tabs/src/tabs.tsx, the renderTabs useMemo dependency array intentionally includes `variant` and `isVertical` to prevent potential side-effects, even though they might appear redundant based on static analysis.

Applied to files:

  • packages/core/theme/src/components/tabs.ts
  • packages/components/tabs/src/tabs.tsx
📚 Learning: 2025-10-25T17:11:59.338Z
Learnt from: adbjo
Repo: heroui-inc/heroui PR: 5846
File: packages/components/tabs/src/tabs.tsx:155-155
Timestamp: 2025-10-25T17:11:59.338Z
Learning: In packages/components/tabs/src/tabs.tsx, the renderTabs useMemo dependency array intentionally includes both `domRef` and `cursorRef` to maintain consistency in how ref objects are handled in dependency arrays, even though ref objects have stable identity across renders.

Applied to files:

  • packages/core/theme/src/components/tabs.ts
  • packages/components/tabs/src/tabs.tsx
📚 Learning: 2025-10-27T21:52:33.324Z
Learnt from: adbjo
Repo: heroui-inc/heroui PR: 5846
File: packages/components/tabs/src/tabs.tsx:115-125
Timestamp: 2025-10-27T21:52:33.324Z
Learning: In packages/components/tabs/src/tabs.tsx, the useEffect dependency array at line 125 intentionally uses `domRef.current` rather than `domRef` because domRef.current can change between renders (when React sets it during the commit phase), whereas domRef itself has stable identity and won't change.

Applied to files:

  • packages/components/tabs/src/tabs.tsx
🔇 Additional comments (7)
packages/core/theme/src/components/tabs.ts (1)

67-77: Excellent data-driven cursor control.

The shift to data attributes (data-[initialized], data-[animated]) gives the Tabs component precise control over cursor visibility and animation timing. This prevents the initial flash of a default-positioned cursor and eliminates unwanted animation during initialization or variant switches.

packages/components/tabs/src/tabs.tsx (6)

4-54: Well-organized setup for cursor management.

The new imports, refs, and variable extractions provide the foundation for responsive cursor behavior. The previousVariant and previousIsVertical refs enable detecting prop changes to coordinate animation states.


77-91: Sophisticated animation coordination logic.

The withAnimationReset wrapper elegantly handles animation state during variant/orientation changes and initialization:

  • Detects prop changes and temporarily disables animation
  • Applies position updates without visual transition
  • Re-enables animation in the next frame after the browser has painted

This prevents unwanted animated transitions when switching variants or on initial mount, directly addressing the PR objectives.


93-114: Cursor positioning logic is sound.

The updateCursorPosition callback correctly:

  • Guards against null refs
  • Computes position from offset properties
  • Applies styles within the animation coordination wrapper

The dependency on cursorRef.current is intentional to handle cursor element remounting scenarios.

Based on learnings


116-126: Appropriate ResizeObserver handling.

The onResize callback correctly uses contentRect to detect when the selected tab has actual content (not just padding/borders), preventing cursor positioning for tabs without rendered content.


128-138: Proper ResizeObserver lifecycle management.

The effect correctly:

  • Creates a new observer when the selected tab changes
  • Observes the selected tab's size changes
  • Cleans up the observer on unmount or when dependencies change

The dependency on domRef.current is intentional for proper re-runs when the DOM reference updates.

Based on learnings


140-184: Clean integration of cursor ref into rendering.

The cursor ref is properly:

  • Attached to the span element (line 146)
  • Included in the memoization dependencies for consistency (line 180)

The conditional rendering logic correctly renders the cursor only when animations are enabled and a tab is selected.

Based on learnings

@treconyl
Copy link

treconyl commented Nov 15, 2025

The latest version is still buggy, the cursor cannot calculate the exact position

<Tab
              key={t.key}
              title={
                <div className="flex items-center gap-2 font-semibold">
                  <Icon icon={t.icon} className="block h-4 w-4" />
                  <span className="text-sm">{t.label}</span>
                </div>
              }
              href={t.href}
            />

@wingkwong
Copy link
Member

@treconyl do you mean the latest production version (2.8.5) or this PR version?

@adbjo
Copy link
Author

adbjo commented Nov 16, 2025

@treconyl do you mean the latest production version (2.8.5) or this PR version?

@wingkwong the snippet from @treconyl seems to work with this PR version:
Screencast from 2025-11-15 17-40-30.webm

Latest production version doesn't size the cursor correctly

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.

3 participants