feat: add animated tabs to ui#115
Conversation
There was a problem hiding this comment.
Pull request overview
This pull request adds animated tabs to the UI by integrating components from the animate-ui library. The changes introduce smooth animations and transitions to the tab interface for improved user experience.
Changes:
- Added new dependencies:
motion(v12.31.1) andradix-ui(v1.4.3) for animation and UI primitives - Implemented new utility hooks (
use-controlled-state,use-auto-height,get-strict-context) to support animation features - Created animated tab components with highlight effects and auto-height transitions
- Updated Sender and Receiver components to use consistent text color styling with utility classes
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
web-app/src/routes/index.tsx |
Integrates new animated tabs components, replacing previous tab implementation |
web-app/src/components/animate-ui/primitives/radix/tabs.tsx |
Core animated tabs primitive components wrapping Radix UI |
web-app/src/components/animate-ui/components/tabs.tsx |
Styled tabs components with animation support |
web-app/src/components/animate-ui/primitives/effects/highlight.tsx |
Highlight animation effect for active tab indicators |
web-app/src/components/animate-ui/primitives/effects/auto-height.tsx |
Auto-height animation wrapper for smooth content transitions |
web-app/src/components/animate-ui/primitives/animate/slot.tsx |
Slot component for flexible rendering with motion support |
web-app/src/hooks/use-controlled-state.tsx |
Hook for managing controlled/uncontrolled state patterns |
web-app/src/hooks/use-auto-height.tsx |
Hook for automatic height measurement with ResizeObserver |
web-app/src/lib/get-strict-context.tsx |
Utility for creating type-safe React contexts |
web-app/src/components/sender/Sender.tsx |
Updated text styling to use utility classes |
web-app/src/components/receiver/Receiver.tsx |
Updated text styling to use utility classes |
package.json |
Added motion and radix-ui dependencies |
components.json |
Added animate-ui registry and changed icon library |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 'use client' | ||
|
|
||
| import * as React from 'react' | ||
| import { Tabs as TabsPrimitive } from 'radix-ui' |
There was a problem hiding this comment.
The import statement import { Tabs as TabsPrimitive } from 'radix-ui' is incorrect. The radix-ui package (v1.4.3) is a meta-package that re-exports individual Radix UI components, but looking at its exports structure, the Tabs component should be imported from @radix-ui/react-tabs instead. This import will likely fail at runtime as the Tabs export may not be available from the main radix-ui package, or if it is, it should be accessed as Tabs from the package's exports structure, not as a nested TabsPrimitive.Root object.
The correct import should be:
import * as TabsPrimitive from '@radix-ui/react-tabs'This ensures proper access to TabsPrimitive.Root, TabsPrimitive.List, TabsPrimitive.Trigger, and TabsPrimitive.Content components.
| import { Tabs as TabsPrimitive } from 'radix-ui' | |
| import * as TabsPrimitive from '@radix-ui/react-tabs' |
| import { SingleLayoutPage } from '@/components/common/SingleLayoutPage' | ||
| import { invoke } from '@tauri-apps/api/core' | ||
| import { useEffect, useRef, useState } from 'react' | ||
| import * as SingleLayoutPage from '@/components/common/SingleLayoutPage' |
There was a problem hiding this comment.
The import statement for SingleLayoutPage has been changed incorrectly. The original file exports SingleLayoutPage as a named export (not a default export or namespace), so the import should be:
import { SingleLayoutPage } from '@/components/common/SingleLayoutPage'Then it should be used directly as <SingleLayoutPage> rather than <SingleLayoutPage.SingleLayoutPage>. The current implementation will fail at runtime because importing as a namespace (import * as SingleLayoutPage) and then accessing it as SingleLayoutPage.SingleLayoutPage is unnecessary and inconsistent with the actual export.
| <SingleLayoutPage.SingleLayoutPage> | ||
| <div className="max-w-2xl mx-auto w-full"> | ||
| <Frame> | ||
| <Tabs value={activeTab} onValueChange={setActiveTab}> | ||
| <Tabs | ||
| value={activeTab} | ||
| onValueChange={(v) => setActiveTab(v as 'send' | 'receive')} | ||
| > | ||
| <FrameHeader> | ||
| <TabsList className="w-full"> | ||
| <TabsTab disabled={isReceiving} value="send"> | ||
| <TabsTrigger disabled={isReceiving} value="send"> | ||
| {t('common:send')} | ||
| </TabsTab> | ||
| <TabsTab disabled={isSharing} value="receive"> | ||
| </TabsTrigger> | ||
| <TabsTrigger disabled={isSharing} value="receive"> | ||
| {t('common:receive')} | ||
| </TabsTab> | ||
| </TabsTrigger> | ||
| </TabsList> | ||
| </FrameHeader> | ||
| <FramePanel> | ||
| <TabsPanel value="send"> | ||
| <TabsContent value="send"> | ||
| <Sender onTransferStateChange={setIsSharing} /> | ||
| </TabsPanel> | ||
| <TabsPanel value="receive"> | ||
| </TabsContent> | ||
| <TabsContent value="receive"> | ||
| <Receiver onTransferStateChange={setIsReceiving} /> | ||
| </TabsPanel> | ||
| </TabsContent> | ||
| </FramePanel> | ||
| </Tabs> | ||
| </Frame> | ||
| </div> | ||
| </SingleLayoutPage> | ||
| </SingleLayoutPage.SingleLayoutPage> |
There was a problem hiding this comment.
The usage of SingleLayoutPage.SingleLayoutPage is incorrect. Since the import on line 3 should be a named import (import { SingleLayoutPage }), this should simply be:
<SingleLayoutPage>and the closing tag on line 89 should be:
</SingleLayoutPage>| "prefix": "" | ||
| }, | ||
| "iconLibrary": "lucide", | ||
| "iconLibrary": "phosphor-react", |
There was a problem hiding this comment.
The icon library has been changed from "lucide" to "phosphor-react", but there's no corresponding package update visible in the package.json changes. If this is an intentional change, ensure that the "phosphor-react" package is added to dependencies. However, this change appears unrelated to the stated PR purpose of "add animated tabs to ui" and may have been included accidentally.
| "iconLibrary": "phosphor-react", | |
| "iconLibrary": "lucide", |
| const isAlreadyMotion = | ||
| typeof children.type === 'object' && | ||
| children.type !== null && | ||
| isMotionComponent(children.type) | ||
|
|
||
| const Base = React.useMemo( | ||
| () => | ||
| isAlreadyMotion | ||
| ? (children.type as React.ElementType) | ||
| : motion.create(children.type as React.ElementType), | ||
| [isAlreadyMotion, children.type] | ||
| ) | ||
|
|
||
| if (!React.isValidElement(children)) return null |
There was a problem hiding this comment.
The Slot component accesses children.type on line 67 before checking if children is a valid React element on line 79. This could cause a runtime error if children is null, undefined, or not a React element. The validation should occur before accessing any properties of children.
Move the if (!React.isValidElement(children)) return null check to the beginning of the function, before the isAlreadyMotion check.
| function Tabs(props: TabsProps) { | ||
| const [value, setValue] = useControlledState({ | ||
| value: props.value, | ||
| defaultValue: props.defaultValue, | ||
| onChange: props.onValueChange, | ||
| }) | ||
|
|
||
| return ( | ||
| <TabsProvider value={{ value, setValue }}> | ||
| <TabsPrimitive.Root | ||
| data-slot="tabs" | ||
| {...props} |
There was a problem hiding this comment.
The Tabs component passes all props (including value and defaultValue) to both useControlledState and TabsPrimitive.Root. This creates a conflict where the state is managed twice - once internally by useControlledState and once by the Radix Tabs primitive. When passing {...props} to TabsPrimitive.Root, the original value prop is included, which will cause the Radix component to compete with the controlled state management.
The component should either:
- Remove
valueanddefaultValuefrom the props spread to TabsPrimitive.Root, and only pass the controlledvaluestate - Or let TabsPrimitive.Root handle the state entirely
The current implementation will lead to unpredictable behavior when both the parent component and Radix try to control the value.
| function Tabs(props: TabsProps) { | |
| const [value, setValue] = useControlledState({ | |
| value: props.value, | |
| defaultValue: props.defaultValue, | |
| onChange: props.onValueChange, | |
| }) | |
| return ( | |
| <TabsProvider value={{ value, setValue }}> | |
| <TabsPrimitive.Root | |
| data-slot="tabs" | |
| {...props} | |
| function Tabs({ | |
| value: valueProp, | |
| defaultValue: defaultValueProp, | |
| onValueChange: onValueChangeProp, | |
| ...restProps | |
| }: TabsProps) { | |
| const [value, setValue] = useControlledState({ | |
| value: valueProp, | |
| defaultValue: defaultValueProp, | |
| onChange: onValueChangeProp, | |
| }) | |
| return ( | |
| <TabsProvider value={{ value, setValue }}> | |
| <TabsPrimitive.Root | |
| data-slot="tabs" | |
| {...restProps} | |
| value={value} |
| // biome-ignore lint/correctness/useExhaustiveDependencies: we want to control deps | ||
| }, deps) |
There was a problem hiding this comment.
The useLayoutEffect on line 61 depends on measure and options.includeParentBox, but the dependency array only includes deps (which are user-provided dependencies). The measure function is a dependency because it's called within the effect, and options.includeParentBox is used within the effect. While the biome-ignore comment indicates this is intentional, omitting measure from the dependencies could lead to stale closures where an old version of the measure function is used.
If the intention is to have controlled dependencies, consider either:
- Adding
measureandoptionsto the dependencies alongsidedeps - Or restructuring to avoid the dependency on
measureby inlining the measurement logic
| // biome-ignore lint/correctness/useExhaustiveDependencies: we want to control deps | |
| }, deps) | |
| }, [measure, options.includeParentBox, ...deps]) |
|
Thanks @blackmouse572 for this cool addition to UI. Merged! |
Description
Add animated tabs for smooth butter UX

Checklist
npm run lintbefore raising this PRnpm run formatbefore raising this PR