From 1b46eeb9907481f8512116af7b5b8ee0ecc196e4 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 16 Sep 2025 16:57:24 -0700 Subject: [PATCH 1/2] feat: Add support for animated selection indicators --- .../s2/src/SegmentedControl.tsx | 59 ++---- packages/@react-spectrum/s2/src/Tabs.tsx | 103 +++------ .../pages/react-aria/SegmentedControl.css | 51 +++++ .../dev/s2-docs/pages/react-aria/Tabs.mdx | 14 +- .../pages/react-aria/ToggleButtonGroup.mdx | 31 +++ packages/react-aria-components/docs/Tabs.mdx | 121 ++++++++--- .../docs/ToggleButtonGroup.mdx | 94 ++++++++- .../src/SharedElementTransition.tsx | 196 ++++++++++++++++++ packages/react-aria-components/src/Tabs.tsx | 9 +- .../src/ToggleButton.tsx | 7 +- .../src/ToggleButtonGroup.tsx | 5 +- packages/react-aria-components/src/index.ts | 2 + starters/docs/src/Tabs.css | 53 +++-- starters/docs/src/Tabs.tsx | 14 +- starters/tailwind/src/Tabs.tsx | 14 +- 15 files changed, 579 insertions(+), 194 deletions(-) create mode 100644 packages/dev/s2-docs/pages/react-aria/SegmentedControl.css create mode 100644 packages/react-aria-components/src/SharedElementTransition.tsx diff --git a/packages/@react-spectrum/s2/src/SegmentedControl.tsx b/packages/@react-spectrum/s2/src/SegmentedControl.tsx index de31da5bde6..0dafd0cc08d 100644 --- a/packages/@react-spectrum/s2/src/SegmentedControl.tsx +++ b/packages/@react-spectrum/s2/src/SegmentedControl.tsx @@ -13,13 +13,13 @@ import {AriaLabelingProps, DOMRef, DOMRefValue, FocusableRef, Key} from '@react-types/shared'; import {baseColor, focusRing, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; -import {ContextValue, DEFAULT_SLOT, Provider, TextContext as RACTextContext, SlotProps, ToggleButton, ToggleButtonGroup, ToggleButtonRenderProps, ToggleGroupStateContext} from 'react-aria-components'; +import {ContextValue, DEFAULT_SLOT, Provider, TextContext as RACTextContext, SelectionIndicator, SlotProps, ToggleButton, ToggleButtonGroup, ToggleButtonRenderProps, ToggleGroupStateContext} from 'react-aria-components'; import {control, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; -import {createContext, forwardRef, ReactNode, RefObject, useCallback, useContext, useRef} from 'react'; +import {createContext, forwardRef, ReactNode, useCallback, useContext, useRef} from 'react'; import {IconContext} from './Icon'; import {pressScale} from './pressScale'; import {Text, TextContext} from './Content'; -import {useDOMRef, useFocusableRef, useMediaQuery} from '@react-spectrum/utils'; +import {useDOMRef, useFocusableRef} from '@react-spectrum/utils'; import {useLayoutEffect} from '@react-aria/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -113,6 +113,13 @@ const slider = style<{isDisabled: boolean}>({ left: 0, width: 'full', height: 'full', + contain: 'strict', + transition: { + default: '[translate,width]', + '@media (prefers-reduced-motion: reduce)': 'none' + }, + transitionDuration: 200, + transitionTimingFunction: 'out', position: 'absolute', boxSizing: 'border-box', borderStyle: 'solid', @@ -130,8 +137,6 @@ const slider = style<{isDisabled: boolean}>({ interface InternalSegmentedControlContextProps { register?: (value: Key, isDisabled?: boolean) => void, - prevRef?: RefObject, - currentSelectedRef?: RefObject, isJustified?: boolean } @@ -139,8 +144,6 @@ interface DefaultSelectionTrackProps { defaultValue?: Key | null, value?: Key | null, children: ReactNode, - prevRef: RefObject, - currentSelectedRef: RefObject, isJustified?: boolean } @@ -158,14 +161,7 @@ export const SegmentedControl = /*#__PURE__*/ forwardRef(function SegmentedContr } = props; let domRef = useDOMRef(ref); - let prevRef = useRef(null); - let currentSelectedRef = useRef(null); - let onChange = (values: Set) => { - if (currentSelectedRef.current) { - prevRef.current = currentSelectedRef?.current.getBoundingClientRect(); - } - if (onSelectionChange) { let firstKey = values.values().next().value; if (firstKey != null) { @@ -186,7 +182,7 @@ export const SegmentedControl = /*#__PURE__*/ forwardRef(function SegmentedContr onSelectionChange={onChange} className={(props.UNSAFE_className || '') + segmentedControl(null, props.styles)} aria-label={props['aria-label']}> - + {props.children} @@ -209,7 +205,7 @@ function DefaultSelectionTracker(props: DefaultSelectionTrackProps) { return ( {props.children} @@ -222,46 +218,21 @@ function DefaultSelectionTracker(props: DefaultSelectionTrackProps) { export const SegmentedControlItem = /*#__PURE__*/ forwardRef(function SegmentedControlItem(props: SegmentedControlItemProps, ref: FocusableRef) { let domRef = useFocusableRef(ref); let divRef = useRef(null); - let {register, prevRef, currentSelectedRef, isJustified} = useContext(InternalSegmentedControlContext); - let state = useContext(ToggleGroupStateContext); - let isSelected = state?.selectedKeys.has(props.id); - // do not apply animation if a user has the prefers-reduced-motion setting - let reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); + let {register, isJustified} = useContext(InternalSegmentedControlContext); useLayoutEffect(() => { register?.(props.id); }, [register, props.id]); - useLayoutEffect(() => { - if (isSelected && prevRef?.current && currentSelectedRef?.current && !reduceMotion) { - let currentItem = currentSelectedRef?.current.getBoundingClientRect(); - - let deltaX = prevRef?.current.left - currentItem?.left; - - currentSelectedRef.current.animate( - [ - {transform: `translateX(${deltaX}px)`, width: `${prevRef?.current.width}px`}, - {transform: 'translateX(0px)', width: `${currentItem.width}px`} - ], - { - duration: 200, - easing: 'ease-out' - } - ); - - prevRef.current = null; - } - }, [isSelected, reduceMotion, prevRef, currentSelectedRef]); - return ( (props.UNSAFE_className || '') + controlItem({...renderProps, isJustified}, props.styles)} > - {({isSelected, isPressed, isDisabled}) => ( + {({isPressed, isDisabled}) => ( <> - {isSelected &&
} + , DOMRefValue>>(null); const InternalTabsContext = createContext & { tablistRef?: RefObject, - prevRef?: RefObject, selectedKey?: Key | null }>({}); @@ -133,14 +133,6 @@ export const Tabs = forwardRef(function Tabs(props: TabsProps, ref: DOMRef(null); - let prevRef = useRef(null); - - let onChange = useEffectEvent((val: Key) => { - if (tablistRef.current) { - prevRef.current = tablistRef.current.querySelector('[role=tab][data-selected=true]')?.getBoundingClientRect() ?? null; - } - setValue(val); - }); return ( )} @@ -294,6 +285,13 @@ const selectedIndicator = style<{isDisabled: boolean, orientation?: Orientation} vertical: '[2px]' } }, + contain: 'strict', + transition: { + default: '[translate,width,height]', + '@media (prefers-reduced-motion: reduce)': 'none' + }, + transitionDuration: 200, + transitionTimingFunction: 'out', bottom: { default: 0 }, @@ -374,7 +372,7 @@ const icon = style({ }); export function Tab(props: TabProps): ReactNode { - let {density, orientation, labelBehavior, prevRef} = useContext(InternalTabsContext) ?? {}; + let {density, orientation, labelBehavior} = useContext(InternalTabsContext) ?? {}; let contentId = useId(); let ariaLabelledBy = props['aria-labelledby'] || ''; @@ -390,7 +388,6 @@ export function Tab(props: TabProps): ReactNode { {({ // @ts-ignore isMenu, - isSelected, isDisabled }) => { if (isMenu) { @@ -417,10 +414,8 @@ export function Tab(props: TabProps): ReactNode { }] ]}> + isDisabled={isDisabled}> {typeof props.children === 'string' ? {props.children} : props.children} @@ -431,53 +426,17 @@ export function Tab(props: TabProps): ReactNode { ); } -function TabInner({isSelected, isDisabled, orientation, children, prevRef}: { - isSelected: boolean, +function TabInner({isDisabled, orientation, children}: { isDisabled: boolean, orientation: Orientation, - children: ReactNode, - prevRef?: RefObject + children: ReactNode }) { - let reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); let ref = useRef(null); - - useLayoutEffect(() => { - if (isSelected && prevRef?.current && ref?.current && !reduceMotion) { - let currentItem = ref?.current.getBoundingClientRect(); - - if (orientation === 'horizontal') { - let deltaX = prevRef.current.left - currentItem.left; - ref.current.animate( - [ - {transform: `translateX(${deltaX}px)`, width: `${prevRef.current.width}px`}, - {transform: 'translateX(0px)', width: '100%'} - ], - { - duration: 200, - easing: 'ease-out' - } - ); - } else { - let deltaY = prevRef.current.top - currentItem.top; - ref.current.animate( - [ - {transform: `translateY(${deltaY}px)`, height: `${prevRef.current.height}px`}, - {transform: 'translateY(0px)', height: '100%'} - ], - { - duration: 200, - easing: 'ease-out' - } - ); - } - - prevRef.current = null; - } - }, [isSelected, reduceMotion, prevRef, orientation]); + let isHidden = useContext(HiddenTabsContext); return ( <> - {isSelected &&
} + {!isHidden && } {children} ); @@ -549,6 +508,8 @@ function isEveryTabDisabled(collection: Collection> | undefined, disa return false; } +const HiddenTabsContext = createContext(false); + let HiddenTabs = function (props: { listRef: RefObject, items: Array>, @@ -573,18 +534,20 @@ let HiddenTabs = function (props: { overflow: 'hidden', opacity: 0 })}> - {items.map((item) => { - // pull off individual props as an allow list, don't want refs or other props getting through - return ( -
- {item.props.children({size, density})} -
- ); - })} + + {items.map((item) => { + // pull off individual props as an allow list, don't want refs or other props getting through + return ( +
+ {item.props.children({size, density})} +
+ ); + })} +
); }; diff --git a/packages/dev/s2-docs/pages/react-aria/SegmentedControl.css b/packages/dev/s2-docs/pages/react-aria/SegmentedControl.css new file mode 100644 index 00000000000..5fefd30e4fc --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/SegmentedControl.css @@ -0,0 +1,51 @@ +.segmented-control { + display: flex; + background: var(--gray-100); + width: fit-content; + padding: 2px; + border-radius: 8px; + + .react-aria-SelectionIndicator { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + transition-property: translate, width; + transition-duration: 200ms; + border-radius: 8px; + background: var(--gray-50); + outline: 2px solid var(--gray-600); + } + + .segmented-control-item { + all: unset; + display: block; + color: var(--text-color); + font-size: 1rem; + outline: none; + padding: 6px 16px; + position: relative; + z-index: 1; + border-radius: 8px; + + span { + display: inline-block; + transition: scale 200ms; + } + + &[data-pressed] span { + scale: 0.95; + } + + &[data-selected] { + z-index: 0; + } + + &[data-focus-visible] { + outline: 2px solid var(--focus-ring-color); + outline-offset: 4px; + } + } +} diff --git a/packages/dev/s2-docs/pages/react-aria/Tabs.mdx b/packages/dev/s2-docs/pages/react-aria/Tabs.mdx index 3d0e1046bdd..6d93031cb7e 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tabs.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tabs.mdx @@ -14,7 +14,7 @@ import Anatomy from '@react-aria/tabs/docs/anatomy.svg'; ```tsx render docs={docs.exports.Tabs} links={docs.links} props={['orientation', 'keyboardActivation', 'isDisabled']} type="vanilla" files={["starters/docs/src/Tabs.tsx", "starters/docs/src/Tabs.css"]} "use client"; - import {Tabs, TabList, Tab, TabPanel} from 'react-aria-components'; + import {Tabs, TabList, Tab, TabPanel} from 'vanilla-starter/Tabs'; import Home from '@react-spectrum/s2/illustrations/gradient/generic2/Home'; import Folder from '@react-spectrum/s2/illustrations/gradient/generic2/FolderOpen'; import Search from '@react-spectrum/s2/illustrations/gradient/generic2/Search'; @@ -80,7 +80,9 @@ import Anatomy from '@react-aria/tabs/docs/anatomy.svg'; ```tsx render "use client"; -import {Tabs, TabList, Tab, TabPanel, Collection, Button} from 'react-aria-components'; +import {Tabs, TabList, Tab, TabPanel} from 'vanilla-starter/Tabs'; +import {Button} from 'vanilla-starter/Button'; +import {Collection} from 'react-aria-components'; import {useState} from 'react'; function Example() { @@ -146,7 +148,7 @@ Use the `href` prop on a `` to create a link. See the **client side routing ```tsx render "use client"; -import {Tabs, TabList, Tab, TabPanel} from 'react-aria-components'; +import {Tabs, TabList, Tab, TabPanel} from 'vanilla-starter/Tabs'; import {useSyncExternalStore} from 'react'; export default function Example() { @@ -189,7 +191,7 @@ Use the `defaultSelectedKey` or `selectedKey` prop to set the selected tab. The ```tsx render "use client"; import type {Key} from 'react-aria-components'; -import {Tabs, TabList, Tab, TabPanel} from 'react-aria-components'; +import {Tabs, TabList, Tab, TabPanel} from 'vanilla-starter/Tabs'; import Home from '@react-spectrum/s2/illustrations/gradient/generic2/Home'; import Folder from '@react-spectrum/s2/illustrations/gradient/generic2/FolderOpen'; import Search from '@react-spectrum/s2/illustrations/gradient/generic2/Search'; @@ -239,7 +241,9 @@ function Example() { ```tsx links={{Tabs: '#tabs', TabList: '#tablist', Tab: '#tab', TabPanel: '#tabpanel'}} - + + + diff --git a/packages/dev/s2-docs/pages/react-aria/ToggleButtonGroup.mdx b/packages/dev/s2-docs/pages/react-aria/ToggleButtonGroup.mdx index be58c669398..b62ef7cbcd4 100644 --- a/packages/dev/s2-docs/pages/react-aria/ToggleButtonGroup.mdx +++ b/packages/dev/s2-docs/pages/react-aria/ToggleButtonGroup.mdx @@ -79,6 +79,37 @@ function Example(props) { } ``` +### Animation + +Render a `SelectionIndicator` within each `ToggleButton` to animate selection changes. + +```tsx render files={['packages/dev/s2-docs/pages/react-aria/SegmentedControl.css']} +"use client"; +import {ToggleButtonGroup, ToggleButton, ToggleButtonProps, SelectionIndicator} from 'react-aria-components'; +import './SegmentedControl.css'; + +function SegmentedControlItem(props: ToggleButtonProps) { + return ( + + {/*- begin highlight -*/} + + {/*- end highlight -*/} + {props.children} + + ); +} + + + Day + Week + Month + Year + +``` + ## API diff --git a/packages/react-aria-components/docs/Tabs.mdx b/packages/react-aria-components/docs/Tabs.mdx index f06157ba739..220e265bb8b 100644 --- a/packages/react-aria-components/docs/Tabs.mdx +++ b/packages/react-aria-components/docs/Tabs.mdx @@ -46,13 +46,22 @@ type: component ## Example ```tsx example -import {Tabs, TabList, Tab, TabPanel} from 'react-aria-components'; +import {Tabs, TabList, Tab, TabPanel, SelectionIndicator} from 'react-aria-components'; - Founding of Rome - Monarchy and Republic - Empire + + Founding of Rome + + + + Monarchy and Republic + + + + Empire + + Arma virumque cano, Troiae qui primus ab oris. @@ -91,7 +100,10 @@ import {Tabs, TabList, Tab, TabPanel} from 'react-aria-components'; &[data-orientation=horizontal] { border-bottom: 1px solid var(--border-color); - .react-aria-Tab { + .react-aria-SelectionIndicator { + left: 0; + bottom: 0; + width: 100%; border-bottom: 3px solid var(--border-color); } } @@ -107,6 +119,12 @@ import {Tabs, TabList, Tab, TabPanel} from 'react-aria-components'; --border-color: transparent; forced-color-adjust: none; + .react-aria-SelectionIndicator { + position: absolute; + transition-property: translate, width, height; + transition-duration: 200ms; + } + &[data-hovered], &[data-focused] { color: var(--text-color-hover); @@ -164,11 +182,13 @@ Tabs consist of a tab list with one or more visually separated tabs. Each tab ha Each tab can be clicked, tapped, or navigated to via arrow keys. Depending on the `keyboardActivation` prop, the tab can be selected by receiving keyboard focus, or it can be selected with the Enter key. ```tsx -import {Tabs, TabList, Tab, TabPanel} from 'react-aria-components'; +import {Tabs, TabList, Tab, TabPanel, SelectionIndicator} from 'react-aria-components'; - + + + @@ -206,6 +226,36 @@ To help kick-start your project, we offer starter kits that include example impl +## Reusable wrappers + +This example wraps the `Tab` component to include a `SelectionIndicator`, which enables animating the tab selection state. + +```tsx example export=true +import {Tabs, TabList, Tab, TabProps, TabPanel, SelectionIndicator, composeRenderProps} from 'react-aria-components'; + +function MyTab(props: TabProps) { + return ( + + {composeRenderProps(props.children, children => (<> + {children} + + ))} + + ); +} + + + + Home + Search + Notifications + + Home content + Search content + Notifications content + +``` + ## Selection ### Default selection @@ -217,9 +267,9 @@ See the [Selection](selection.html) guide for more details. ```tsx example - Mouse Settings - Keyboard Settings - Gamepad Settings + Mouse Settings + Keyboard Settings + Gamepad Settings Mouse Settings Keyboard Settings @@ -242,9 +292,9 @@ function Example() {

Selected time period: {timePeriod}

- Triassic - Jurassic - Cretaceous + Triassic + Jurassic + Cretaceous The Triassic ranges roughly from 252 million to 201 million years ago, preceding the Jurassic Period. @@ -270,9 +320,9 @@ tab selection. ```tsx example - Mouse Settings - Keyboard Settings - Gamepad Settings + Mouse Settings + Keyboard Settings + Gamepad Settings Mouse Settings Keyboard Settings @@ -291,9 +341,9 @@ This example uses the same `Tabs` component from above. Try navigating from the ```tsx example - Jane Doe - John Doe - Joe Bloggs + Jane Doe + John Doe + Joe Bloggs @@ -340,7 +390,7 @@ function Example() {
- {item => {item.title}} + {item => {item.title}}
@@ -376,9 +426,9 @@ By default, tabs are horizontally oriented. The `orientation` prop can be set to ```tsx example - John Doe - Jane Doe - Joe Bloggs + John Doe + Jane Doe + Joe Bloggs There is no prior chat history with John Doe. There is no prior chat history with Jane Doe. @@ -402,7 +452,10 @@ By default, tabs are horizontally oriented. The `orientation` prop can be set to flex-direction: column; border-inline-end: 1px solid gray; - .react-aria-Tab { + .react-aria-SelectionIndicator { + top: 0; + right: 0; + height: 100%; border-inline-end: 3px solid var(--border-color, transparent); } } @@ -418,9 +471,9 @@ All tabs can be disabled using the `isDisabled` prop. ```tsx example - Mouse Settings - Keyboard Settings - Gamepad Settings + Mouse Settings + Keyboard Settings + Gamepad Settings Mouse Settings Keyboard Settings @@ -451,10 +504,10 @@ An individual `Tab` can be disabled with the `isDisabled` prop. Disabled tabs ar ```tsx example - Mouse Settings - Keyboard Settings + Mouse Settings + Keyboard Settings {/*- begin highlight -*/} - Gamepad Settings + Gamepad Settings {/*- end highlight -*/} Mouse Settings @@ -478,7 +531,7 @@ function Example() { return ( - {item => {item.title}} + {item => {item.title}} {item => {item.title}} @@ -507,9 +560,9 @@ function AppTabs() { return ( - Home - Shared - Deleted + Home + Shared + Deleted diff --git a/packages/react-aria-components/docs/ToggleButtonGroup.mdx b/packages/react-aria-components/docs/ToggleButtonGroup.mdx index 4d14698924e..e2631cbb57a 100644 --- a/packages/react-aria-components/docs/ToggleButtonGroup.mdx +++ b/packages/react-aria-components/docs/ToggleButtonGroup.mdx @@ -116,10 +116,12 @@ There is no built in element for toggle button groups in HTML. `ToggleButtonGrou A toggle button group consists of a set of toggle buttons, and coordinates the selection state between them. Users can navigate between buttons with the arrow keys in either horizontal or vertical orientations. ```tsx -import {ToggleButtonGroup, ToggleButton} from 'react-aria-components'; +import {ToggleButtonGroup, ToggleButton, SelectionIndicator} from 'react-aria-components'; - + + + ``` @@ -187,6 +189,94 @@ function Example() { } ``` +### Animation + +Use the `SelectionIndicator` component to animate selection changes. + +```tsx example +import {ToggleButtonGroup, ToggleButton, SelectionIndicator} from 'react-aria-components'; + + + + + Day + + + + Week + + + + Month + + + + Year + + +``` + +
+ Show CSS + +```css +.segmented-control { + display: flex; + background: var(--gray-100); + width: fit-content; + padding: 2px; + border-radius: 8px; + + .react-aria-SelectionIndicator { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + transition-property: translate, width; + transition-duration: 200ms; + border-radius: 8px; + background: var(--gray-50); + outline: 2px solid var(--gray-600); + } + + .react-aria-ToggleButton { + all: unset; + color: var(--text-color); + font-size: 1rem; + outline: none; + padding: 4px 16px; + position: relative; + z-index: 1; + border-radius: 8px; + + span { + display: inline-block; + transition: scale 200ms; + } + + &[data-pressed] span { + scale: 0.95; + } + + &[data-selected] { + z-index: 0; + } + + &[data-focus-visible] { + outline: 2px solid var(--focus-ring-color); + outline-offset: 4px; + } + } +} +``` + +
+ ## Disabled All buttons within a `ToggleButtonGroup` can be disabled using the `isDisabled` prop. diff --git a/packages/react-aria-components/src/SharedElementTransition.tsx b/packages/react-aria-components/src/SharedElementTransition.tsx new file mode 100644 index 00000000000..4b376a9dc03 --- /dev/null +++ b/packages/react-aria-components/src/SharedElementTransition.tsx @@ -0,0 +1,196 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {flushSync} from 'react-dom'; +import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, ReactNode, RefObject, useContext, useRef, useState} from 'react'; +import {RenderProps, useRenderProps} from './utils'; +import {useLayoutEffect} from '@react-aria/utils'; +import {useObjectRef} from 'react-aria'; + +interface Snapshot { + rect: DOMRect, + style: [string, string][] +} + +const SharedElementContext = createContext | null>(null); + +export interface SharedElementTransitionProps { + children: ReactNode +} + +/** + * A scope for SharedElements, which animate between parents. + */ +export function SharedElementTransition(props: SharedElementTransitionProps) { + let ref = useRef({}); + return ( + + {props.children} + + ); +} + +export interface SharedElementRenderProps { + isEntering?: boolean, + isExiting?: boolean +} + +interface SharedElementPropsBase extends Omit, 'children' | 'className' | 'style'>, RenderProps {} + +export interface SharedElementProps extends SharedElementPropsBase { + name: string, + isVisible?: boolean +} + +/** + * An element that animates between its old and new position when moving between parents. + */ +export const SharedElement = forwardRef(function SharedElement(props: SharedElementProps, ref: ForwardedRef) { + let {name, isVisible = true, children, className, style, ...divProps} = props; + let [state, setState] = useState(isVisible ? 'visible' : 'hidden'); + let scopeRef = useContext(SharedElementContext); + if (!scopeRef) { + throw new Error(' must be rendered inside a '); + } + + if (isVisible && state === 'hidden') { + setState('visible'); + } + + ref = useObjectRef(ref); + useLayoutEffect(() => { + let element = ref.current; + let scope = scopeRef.current; + let prevSnapshot = scope[name]; + let frame: number | null = null; + + if (element && isVisible && prevSnapshot) { + // Element is transitioning from a previous instance. + setState('visible'); + let animations = element.getAnimations(); + + // Set properties to animate from. + let values = prevSnapshot.style.map(([property, prevValue]) => { + let value = element.style[property]; + if (property === 'translate') { + let prevRect = prevSnapshot.rect; + let currentItem = element.getBoundingClientRect(); + let deltaX = prevRect.left - currentItem?.left; + let deltaY = prevRect.top - currentItem?.top; + element.style.translate = `${deltaX}px ${deltaY}px`; + } else { + element.style[property] = prevValue; + } + return [property, value]; + }); + + // Cancel any new animations triggered by these properties. + for (let a of element.getAnimations()) { + if (!animations.includes(a)) { + a.cancel(); + } + } + + // Remove overrides after one frame to animate to the current values. + frame = requestAnimationFrame(() => { + frame = null; + for (let [property, value] of values) { + element.style[property] = value; + } + }); + + delete scope[name]; + } else if (element && isVisible && !prevSnapshot) { + // No previous instance exists, apply the entering state. + queueMicrotask(() => flushSync(() => setState('entering'))); + frame = requestAnimationFrame(() => { + frame = null; + setState('visible'); + }); + } else if (element && !isVisible) { + // Wait until layout effects finish, and check if a snapshot still exists. + // If so, no new SharedElement consumed it, so enter the exiting state. + queueMicrotask(() => { + if (scope[name]) { + delete scope[name]; + flushSync(() => setState('exiting')); + Promise.all(element.getAnimations().map(a => a.finished)) + .then(() => setState('hidden')) + .catch(() => {}); + } else { + // Snapshot was consumed by another instance, unmount. + setState('hidden'); + } + }); + } + + return () => { + if (frame != null) { + cancelAnimationFrame(frame); + } + + if (element && element.isConnected && !element.hasAttribute('data-exiting')) { + // On unmount, store a snapshot of the rectangle and computed style for transitioning properties. + let style = window.getComputedStyle(element); + if (style.transitionProperty !== 'none') { + let transitionProperty = style.transitionProperty.split(/\s*,\s*/); + scope[name] = { + rect: element.getBoundingClientRect(), + style: transitionProperty.map(p => [p, style[p]]) + }; + } + } + }; + }, [ref, scopeRef, name, isVisible]); + + let renderProps = useRenderProps({ + children, + className, + style, + values: { + isEntering: state === 'entering', + isExiting: state === 'exiting' + } + }); + + if (state === 'hidden') { + return null; + } + + return ( +
+ ); +}); + +export const SelectionIndicatorContext = createContext({isSelected: false}); + +export interface SelectionIndicatorProps extends SharedElementPropsBase {} + +/** + * An animated indicator of selection state within a group of items. + */ +export const SelectionIndicator = forwardRef(function SelectionIndicator(props: SelectionIndicatorProps, ref: ForwardedRef) { + let {isSelected} = useContext(SelectionIndicatorContext); + return ( + + ); +}); diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index 7a0114a8606..7358c71740a 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -18,6 +18,7 @@ import {ContextValue, Provider, RenderProps, SlotProps, StyleRenderProps, useCon import {filterDOMProps, inertValue, useObjectRef} from '@react-aria/utils'; import {Collection as ICollection, Node, TabListState, useTabListState} from 'react-stately'; import React, {createContext, ForwardedRef, forwardRef, JSX, useContext, useMemo} from 'react'; +import {SelectionIndicatorContext, SharedElementTransition} from './SharedElementTransition'; export interface TabsProps extends Omit, 'items' | 'children'>, RenderProps, SlotProps, GlobalDOMAttributes {} @@ -230,7 +231,9 @@ function TabListInner({props, forwardedRef: ref}: TabListInner {...mergeProps(DOMProps, renderProps, tabListProps)} ref={objectRef} data-orientation={orientation || undefined}> - + + +
); } @@ -284,7 +287,9 @@ export const Tab = /*#__PURE__*/ createLeafComponent(TabItemNode, (props: TabPro data-focus-visible={isFocusVisible || undefined} data-pressed={isPressed || undefined} data-hovered={isHovered || undefined}> - {renderProps.children} + + {renderProps.children} + ); }); diff --git a/packages/react-aria-components/src/ToggleButton.tsx b/packages/react-aria-components/src/ToggleButton.tsx index c42b7f6881d..e3ba4eab3a5 100644 --- a/packages/react-aria-components/src/ToggleButton.tsx +++ b/packages/react-aria-components/src/ToggleButton.tsx @@ -16,6 +16,7 @@ import {ContextValue, RenderProps, SlotProps, useContextProps, useRenderProps} f import {filterDOMProps} from '@react-aria/utils'; import {forwardRefType, GlobalDOMAttributes, Key} from '@react-types/shared'; import React, {createContext, ForwardedRef, forwardRef, useContext} from 'react'; +import {SelectionIndicatorContext} from './SharedElementTransition'; import {ToggleGroupStateContext} from './ToggleButtonGroup'; import {ToggleState, useToggleState} from 'react-stately'; @@ -80,6 +81,10 @@ export const ToggleButton = /*#__PURE__*/ (forwardRef as forwardRefType)(functio data-pressed={isPressed || undefined} data-selected={isSelected || undefined} data-hovered={isHovered || undefined} - data-focus-visible={isFocusVisible || undefined} /> + data-focus-visible={isFocusVisible || undefined}> + + {renderProps.children} + + ); }); diff --git a/packages/react-aria-components/src/ToggleButtonGroup.tsx b/packages/react-aria-components/src/ToggleButtonGroup.tsx index e22ab95d300..5a84cf46ca3 100644 --- a/packages/react-aria-components/src/ToggleButtonGroup.tsx +++ b/packages/react-aria-components/src/ToggleButtonGroup.tsx @@ -14,6 +14,7 @@ import {ContextValue, RenderProps, SlotProps, useContextProps, useRenderProps} f import {filterDOMProps, mergeProps} from '@react-aria/utils'; import {forwardRefType, GlobalDOMAttributes} from '@react-types/shared'; import React, {createContext, ForwardedRef, forwardRef} from 'react'; +import {SharedElementTransition} from './SharedElementTransition'; import {ToggleGroupState, useToggleGroupState} from 'react-stately'; export interface ToggleButtonGroupRenderProps { @@ -60,7 +61,9 @@ export const ToggleButtonGroup = /*#__PURE__*/ (forwardRef as forwardRefType)(fu data-orientation={props.orientation || 'horizontal'} data-disabled={props.isDisabled || undefined}> - {renderProps.children} + + {renderProps.children} +
); diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 36cf3e61b43..d4a414149ee 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -61,6 +61,7 @@ export {RadioGroup, Radio, RadioGroupContext, RadioContext, RadioGroupStateConte export {SearchField, SearchFieldContext} from './SearchField'; export {Select, SelectValue, SelectContext, SelectValueContext, SelectStateContext} from './Select'; export {Separator, SeparatorContext} from './Separator'; +export {SharedElementTransition, SharedElement, SelectionIndicator, SelectionIndicatorContext} from './SharedElementTransition'; export {Slider, SliderOutput, SliderTrack, SliderThumb, SliderContext, SliderOutputContext, SliderTrackContext, SliderStateContext} from './Slider'; export {Switch, SwitchContext} from './Switch'; export {TableLoadMoreItem, Table, Row, Cell, Column, ColumnResizer, TableHeader, TableBody, TableContext, ResizableTableContainer, useTableOptions, TableStateContext, TableColumnResizeStateContext} from './Table'; @@ -125,6 +126,7 @@ export type {ProgressBarProps, ProgressBarRenderProps} from './ProgressBar'; export type {RadioGroupProps, RadioGroupRenderProps, RadioProps, RadioRenderProps} from './RadioGroup'; export type {SearchFieldProps, SearchFieldRenderProps} from './SearchField'; export type {SelectProps, SelectValueProps, SelectValueRenderProps, SelectRenderProps} from './Select'; +export type {SharedElementTransitionProps, SharedElementProps, SharedElementRenderProps, SelectionIndicatorProps} from './SharedElementTransition'; export type {SeparatorProps} from './Separator'; export type {SliderOutputProps, SliderProps, SliderRenderProps, SliderThumbProps, SliderTrackProps, SliderTrackRenderProps, SliderThumbRenderProps} from './Slider'; export type {SwitchProps, SwitchRenderProps} from './Switch'; diff --git a/starters/docs/src/Tabs.css b/starters/docs/src/Tabs.css index 66ce290a717..6b78355aedb 100644 --- a/starters/docs/src/Tabs.css +++ b/starters/docs/src/Tabs.css @@ -7,6 +7,10 @@ &[data-orientation=horizontal] { flex-direction: column; } + + &[data-orientation=vertical] { + flex-direction: row; + } } .react-aria-TabList { @@ -15,10 +19,25 @@ &[data-orientation=horizontal] { border-bottom: 1px solid var(--border-color); - .react-aria-Tab { + .react-aria-SelectionIndicator { + left: 0; + bottom: 0; + width: 100%; border-bottom: 3px solid var(--border-color); } } + + &[data-orientation=vertical] { + flex-direction: column; + border-inline-end: 1px solid gray; + + .react-aria-SelectionIndicator { + top: 0; + right: 0; + height: 100%; + border-inline-end: 3px solid var(--border-color, transparent); + } + } } .react-aria-Tab { @@ -36,6 +55,12 @@ color: var(--text-color-hover); } + .react-aria-SelectionIndicator { + position: absolute; + transition-property: translate, width, height; + transition-duration: 200ms; + } + &[data-selected] { --border-color: var(--highlight-background); color: var(--text-color); @@ -68,32 +93,6 @@ } } -.react-aria-Tabs { - &[data-orientation=vertical] { - flex-direction: row; - } -} - -.react-aria-TabList { - &[data-orientation=vertical] { - flex-direction: column; - border-inline-end: 1px solid gray; - - .react-aria-Tab { - border-inline-end: 3px solid var(--border-color, transparent); - } - } -} - -.react-aria-Tab { - &[data-disabled] { - color: var(--text-color-disabled); - &[data-selected] { - --border-color: var(--border-color-disabled); - } - } -} - .react-aria-Tab[href] { text-decoration: none; cursor: pointer; diff --git a/starters/docs/src/Tabs.tsx b/starters/docs/src/Tabs.tsx index e0db790e1db..ac922e957e2 100644 --- a/starters/docs/src/Tabs.tsx +++ b/starters/docs/src/Tabs.tsx @@ -7,7 +7,10 @@ import { Tab as RACTab, TabsProps, TabPanelProps, - TabPanel as RACTabPanel} from 'react-aria-components'; + TabPanel as RACTabPanel, + composeRenderProps, + SelectionIndicator +} from 'react-aria-components'; import './Tabs.css'; export function Tabs(props: TabsProps) { @@ -19,7 +22,14 @@ export function TabList(props: TabListProps) { } export function Tab(props: TabProps) { - return ; + return ( + + {composeRenderProps(props.children, children => (<> + {children} + + ))} + + ); } export function TabPanel(props: TabPanelProps) { diff --git a/starters/tailwind/src/Tabs.tsx b/starters/tailwind/src/Tabs.tsx index c4632272c03..4b7182afc6a 100644 --- a/starters/tailwind/src/Tabs.tsx +++ b/starters/tailwind/src/Tabs.tsx @@ -5,6 +5,7 @@ import { TabList as RACTabList, TabPanel as RACTabPanel, Tabs as RACTabs, + SelectionIndicator, TabListProps, TabPanelProps, TabProps, @@ -58,12 +59,8 @@ export function TabList(props: TabListProps) { const tabProps = tv({ extend: focusRing, - base: 'flex items-center cursor-default rounded-full px-4 py-1.5 text-sm font-medium transition forced-color-adjust-none', + base: 'relative flex items-center cursor-default rounded-full px-4 py-1.5 text-sm font-medium transition forced-color-adjust-none', variants: { - isSelected: { - false: 'text-gray-600 dark:text-zinc-300 hover:text-gray-700 pressed:text-gray-700 dark:hover:text-zinc-200 dark:pressed:text-zinc-200 hover:bg-gray-200 dark:hover:bg-zinc-800 pressed:bg-gray-200 dark:pressed:bg-zinc-800', - true: 'text-white dark:text-black forced-colors:text-[HighlightText] bg-gray-800 dark:bg-zinc-200 forced-colors:bg-[Highlight]' - }, isDisabled: { true: 'text-gray-200 dark:text-zinc-600 forced-colors:text-[GrayText] selected:text-gray-300 dark:selected:text-zinc-500 forced-colors:selected:text-[HighlightText] selected:bg-gray-200 dark:selected:bg-zinc-600 forced-colors:selected:bg-[GrayText]' } @@ -77,7 +74,12 @@ export function Tab(props: TabProps) { className={composeRenderProps( props.className, (className, renderProps) => tabProps({...renderProps, className}) - )} /> + )}> + {composeRenderProps(props.children, children => (<> + {children} + + ))} + ); } From 80b9c167bf841a614b2304fa10ac0d8a5ca43489 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 22 Sep 2025 12:01:31 -0700 Subject: [PATCH 2/2] Add SelectionIndicator support for all other selectable components --- .../dev/s2-docs/pages/react-aria/GridList.mdx | 4 +- .../dev/s2-docs/pages/react-aria/ListBox.mdx | 3 +- .../dev/s2-docs/pages/react-aria/Menu.mdx | 3 +- .../s2-docs/pages/react-aria/RadioGroup.mdx | 6 ++- .../pages/react-aria/SelectionIndicator.css | 39 ++++++++++++++++++ .../dev/s2-docs/pages/react-aria/Table.mdx | 6 ++- .../dev/s2-docs/pages/react-aria/Tabs.mdx | 2 +- .../dev/s2-docs/pages/react-aria/TagGroup.mdx | 3 +- .../pages/react-aria/ToggleButtonGroup.mdx | 6 ++- .../dev/s2-docs/pages/react-aria/Tree.mdx | 4 +- .../s2-docs/pages/react-aria/selection.mdx | 31 ++++++++++++++ packages/dev/s2-docs/src/StateTable.tsx | 9 ++++- .../react-aria-components/docs/GridList.mdx | 4 +- .../react-aria-components/docs/ListBox.mdx | 3 +- packages/react-aria-components/docs/Menu.mdx | 3 +- .../react-aria-components/docs/RadioGroup.mdx | 6 ++- packages/react-aria-components/docs/Table.mdx | 6 ++- .../react-aria-components/docs/TagGroup.mdx | 3 +- packages/react-aria-components/docs/Tree.mdx | 4 +- .../react-aria-components/src/GridList.tsx | 17 +++++--- .../react-aria-components/src/ListBox.tsx | 17 +++++--- packages/react-aria-components/src/Menu.tsx | 15 ++++--- .../react-aria-components/src/RadioGroup.tsx | 10 ++++- .../src/SelectionIndicator.tsx | 40 +++++++++++++++++++ .../src/SharedElementTransition.tsx | 33 +++++---------- packages/react-aria-components/src/Table.tsx | 15 ++++--- packages/react-aria-components/src/Tabs.tsx | 3 +- .../react-aria-components/src/TagGroup.tsx | 13 ++++-- .../src/ToggleButton.tsx | 2 +- packages/react-aria-components/src/Tree.tsx | 17 +++++--- packages/react-aria-components/src/index.ts | 6 ++- 31 files changed, 246 insertions(+), 87 deletions(-) create mode 100644 packages/dev/s2-docs/pages/react-aria/SelectionIndicator.css create mode 100644 packages/react-aria-components/src/SelectionIndicator.tsx diff --git a/packages/dev/s2-docs/pages/react-aria/GridList.mdx b/packages/dev/s2-docs/pages/react-aria/GridList.mdx index 7fcb080411e..894d2be8efb 100644 --- a/packages/dev/s2-docs/pages/react-aria/GridList.mdx +++ b/packages/dev/s2-docs/pages/react-aria/GridList.mdx @@ -263,11 +263,11 @@ function Example() { -```tsx links={{GridList: '#gridlist', GridListItem: '#gridlistitem', GridListLoadMoreItem: '#gridlistloadmoreitem', Button: 'Button.html', Checkbox: 'Checkbox.html'}} +```tsx links={{GridList: '#gridlist', GridListItem: '#gridlistitem', GridListLoadMoreItem: '#gridlistloadmoreitem', Button: 'Button.html', Checkbox: 'Checkbox.html', SelectionIndicator: 'selection.html#animated-selectionindicator'}}