diff --git a/change/@fluentui-react-motion-components-preview-32aac0a2-6998-44d1-a90e-ea35ad4f9e92.json b/change/@fluentui-react-motion-components-preview-32aac0a2-6998-44d1-a90e-ea35ad4f9e92.json new file mode 100644 index 00000000000000..204e6085626bc1 --- /dev/null +++ b/change/@fluentui-react-motion-components-preview-32aac0a2-6998-44d1-a90e-ea35ad4f9e92.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat(motion): add Rotate presence motion component", + "packageName": "@fluentui/react-motion-components-preview", + "email": "robertpenner@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-motion-components-preview/library/etc/react-motion-components-preview.api.md b/packages/react-components/react-motion-components-preview/library/etc/react-motion-components-preview.api.md index 83387bd6e7224f..0e4cca633516cb 100644 --- a/packages/react-components/react-motion-components-preview/library/etc/react-motion-components-preview.api.md +++ b/packages/react-components/react-motion-components-preview/library/etc/react-motion-components-preview.api.md @@ -9,6 +9,11 @@ import { PresenceComponent } from '@fluentui/react-motion'; // @public export const Blur: PresenceComponent; +// @public (undocumented) +export type BlurParams = PresenceDuration & PresenceEasing & AnimateOpacity & { + fromRadius?: string; +}; + // @public export const Collapse: PresenceComponent; @@ -30,6 +35,16 @@ export const FadeRelaxed: PresenceComponent; // @public (undocumented) export const FadeSnappy: PresenceComponent; +// @public (undocumented) +export const Rotate: PresenceComponent; + +// @public (undocumented) +export type RotateParams = PresenceDuration & PresenceEasing & AnimateOpacity & { + axis?: Axis3D; + angle?: number; + exitAngle?: number; +}; + // @public export const Scale: PresenceComponent; diff --git a/packages/react-components/react-motion-components-preview/library/src/atoms/rotate-atom.test.ts b/packages/react-components/react-motion-components-preview/library/src/atoms/rotate-atom.test.ts new file mode 100644 index 00000000000000..10159b29aa78e0 --- /dev/null +++ b/packages/react-components/react-motion-components-preview/library/src/atoms/rotate-atom.test.ts @@ -0,0 +1,129 @@ +import { motionTokens } from '@fluentui/react-motion'; +import { rotateAtom } from './rotate-atom'; + +describe('rotateAtom', () => { + it('creates enter keyframes with rotation from angle to 0', () => { + const atom = rotateAtom({ + direction: 'enter', + duration: 300, + easing: motionTokens.curveEasyEase, + angle: -90, + }); + + expect(atom).toMatchObject({ + duration: 300, + easing: motionTokens.curveEasyEase, + keyframes: [{ rotate: 'y -90deg' }, { rotate: 'y 0deg' }], + }); + }); + + it('creates exit keyframes with rotation from 0 to exitAngle', () => { + const atom = rotateAtom({ + direction: 'exit', + duration: 250, + easing: motionTokens.curveAccelerateMin, + exitAngle: 90, + }); + + expect(atom).toMatchObject({ + duration: 250, + easing: motionTokens.curveAccelerateMin, + keyframes: [{ rotate: 'y 0deg' }, { rotate: 'y 90deg' }], + }); + }); + + it('uses default angle when not provided', () => { + const atom = rotateAtom({ + direction: 'enter', + duration: 300, + }); + + expect(atom.keyframes).toEqual([{ rotate: 'y -90deg' }, { rotate: 'y 0deg' }]); + }); + + it('uses default easing when not provided', () => { + const atom = rotateAtom({ + direction: 'enter', + duration: 300, + angle: 45, + }); + + expect(atom.easing).toBe(motionTokens.curveLinear); + }); + + it('uses default axis when not provided', () => { + const atom = rotateAtom({ + direction: 'enter', + duration: 300, + angle: 180, + }); + + expect(atom.keyframes[0]).toEqual({ rotate: 'y 180deg' }); + expect(atom.keyframes[1]).toEqual({ rotate: 'y 0deg' }); + }); + + it('handles different rotation axes', () => { + const atomX = rotateAtom({ + direction: 'enter', + duration: 300, + axis: 'x', + angle: 45, + }); + + const atomY = rotateAtom({ + direction: 'enter', + duration: 300, + axis: 'y', + angle: 45, + }); + + const atomZ = rotateAtom({ + direction: 'enter', + duration: 300, + axis: 'z', + angle: 45, + }); + + expect(atomX.keyframes[0]).toEqual({ rotate: 'x 45deg' }); + expect(atomY.keyframes[0]).toEqual({ rotate: 'y 45deg' }); + expect(atomZ.keyframes[0]).toEqual({ rotate: 'z 45deg' }); + }); + + it('uses exitAngle when direction is exit', () => { + const atom = rotateAtom({ + direction: 'exit', + duration: 300, + angle: -90, + exitAngle: 45, + }); + + expect(atom.keyframes).toEqual([{ rotate: 'y 0deg' }, { rotate: 'y 45deg' }]); + }); + + it('uses negated angle as default exitAngle', () => { + const atom = rotateAtom({ + direction: 'exit', + duration: 300, + angle: -90, + }); + + expect(atom.keyframes).toEqual([{ rotate: 'y 0deg' }, { rotate: 'y 90deg' }]); + }); + + it('handles positive and negative angle values', () => { + const atomPositive = rotateAtom({ + direction: 'enter', + duration: 300, + angle: 90, + }); + + const atomNegative = rotateAtom({ + direction: 'enter', + duration: 300, + angle: -45, + }); + + expect(atomPositive.keyframes[0]).toEqual({ rotate: 'y 90deg' }); + expect(atomNegative.keyframes[0]).toEqual({ rotate: 'y -45deg' }); + }); +}); diff --git a/packages/react-components/react-motion-components-preview/library/src/atoms/rotate-atom.ts b/packages/react-components/react-motion-components-preview/library/src/atoms/rotate-atom.ts new file mode 100644 index 00000000000000..3cfdd073721edc --- /dev/null +++ b/packages/react-components/react-motion-components-preview/library/src/atoms/rotate-atom.ts @@ -0,0 +1,51 @@ +import { AtomMotion, PresenceDirection, motionTokens } from '@fluentui/react-motion'; +import type { RotateParams } from '../components/Rotate/rotate-types'; + +type Axis3D = NonNullable; + +interface RotateAtomParams { + direction: PresenceDirection; + duration: number; + easing?: string; + axis?: Axis3D; + angle?: number; + exitAngle?: number; +} + +const createRotateValue = (axis: Axis3D, angle: number): string => { + return `${axis.toLowerCase()} ${angle}deg`; +}; + +/** + * Generates a motion atom object for a rotation around a single axis. + * @param direction - The functional direction of the motion: 'enter' or 'exit'. + * @param duration - The duration of the motion in milliseconds. + * @param easing - The easing curve for the motion. Defaults to `motionTokens.curveLinear`. + * @param axis - The axis of rotation: 'x', 'y', or 'z'. Defaults to 'y'. + * @param angle - The starting rotation angle in degrees. Defaults to -90. + * @param exitAngle - The ending rotation angle in degrees. Defaults to the negation of `angle`. + * @returns A motion atom object with rotate keyframes and the supplied duration and easing. + */ +export const rotateAtom = ({ + direction, + duration, + easing = motionTokens.curveLinear, + axis = 'y', + angle = -90, + exitAngle = -angle, +}: RotateAtomParams): AtomMotion => { + let fromAngle = angle; + let toAngle = 0; + + if (direction === 'exit') { + fromAngle = 0; + toAngle = exitAngle; + } + const keyframes = [{ rotate: createRotateValue(axis, fromAngle) }, { rotate: createRotateValue(axis, toAngle) }]; + + return { + keyframes, + duration, + easing, + }; +}; diff --git a/packages/react-components/react-motion-components-preview/library/src/components/Rotate/Rotate.test.ts b/packages/react-components/react-motion-components-preview/library/src/components/Rotate/Rotate.test.ts new file mode 100644 index 00000000000000..f0d73ac43341e8 --- /dev/null +++ b/packages/react-components/react-motion-components-preview/library/src/components/Rotate/Rotate.test.ts @@ -0,0 +1,12 @@ +import { expectPresenceMotionFunction, expectPresenceMotionArray } from '../../testing/testUtils'; +import { Rotate } from './Rotate'; + +describe('Rotate', () => { + it('stores its motion definition as a static function', () => { + expectPresenceMotionFunction(Rotate); + }); + + it('generates a motion definition from the static function', () => { + expectPresenceMotionArray(Rotate); + }); +}); diff --git a/packages/react-components/react-motion-components-preview/library/src/components/Rotate/Rotate.ts b/packages/react-components/react-motion-components-preview/library/src/components/Rotate/Rotate.ts new file mode 100644 index 00000000000000..e58ee249f2020e --- /dev/null +++ b/packages/react-components/react-motion-components-preview/library/src/components/Rotate/Rotate.ts @@ -0,0 +1,62 @@ +import { AtomMotion, createPresenceComponent, motionTokens, PresenceMotionFn } from '@fluentui/react-motion'; +import { fadeAtom } from '../../atoms/fade-atom'; +import { rotateAtom } from '../../atoms/rotate-atom'; +import { RotateParams } from './rotate-types'; + +/** + * Define a presence motion for rotate in/out + * + * @param duration - Time (ms) for the enter transition (rotate-in). Defaults to the `durationGentle` value. + * @param easing - Easing curve for the enter transition (rotate-in). Defaults to the `curveDecelerateMax` value. + * @param exitDuration - Time (ms) for the exit transition (rotate-out). Defaults to the `duration` param for symmetry. + * @param exitEasing - Easing curve for the exit transition (rotate-out). Defaults to the `curveAccelerateMax` value. + * @param axis - The axis of rotation: 'x', 'y', or 'z'. Defaults to 'y'. + * @param angle - The starting rotation angle in degrees. Defaults to -90. + * @param exitAngle - The ending rotation angle in degrees. Defaults to the negation of `angle`. + * @param animateOpacity - Whether to animate the opacity during the rotation. Defaults to `true`. + */ +const rotatePresenceFn: PresenceMotionFn = ({ + axis = 'y', + angle = -90, + exitAngle = -angle, + duration = motionTokens.durationGentle, + exitDuration = duration, + easing = motionTokens.curveDecelerateMax, + exitEasing = motionTokens.curveAccelerateMax, + animateOpacity = true, +}: RotateParams) => { + const enterAtoms: AtomMotion[] = [ + rotateAtom({ + direction: 'enter', + duration, + easing, + axis, + angle, + exitAngle, + }), + ]; + + const exitAtoms: AtomMotion[] = [ + rotateAtom({ + direction: 'exit', + duration: exitDuration, + easing: exitEasing, + axis, + angle, + exitAngle, + }), + ]; + + if (animateOpacity) { + enterAtoms.push(fadeAtom({ direction: 'enter', duration, easing })); + exitAtoms.push(fadeAtom({ direction: 'exit', duration: exitDuration, easing: exitEasing })); + } + + return { + enter: enterAtoms, + exit: exitAtoms, + }; +}; + +// Create a presence motion component to rotate an element around a single axis (x, y, or z). +export const Rotate = createPresenceComponent(rotatePresenceFn); diff --git a/packages/react-components/react-motion-components-preview/library/src/components/Rotate/index.ts b/packages/react-components/react-motion-components-preview/library/src/components/Rotate/index.ts new file mode 100644 index 00000000000000..bc4f2efa9e5539 --- /dev/null +++ b/packages/react-components/react-motion-components-preview/library/src/components/Rotate/index.ts @@ -0,0 +1,2 @@ +export { Rotate } from './Rotate'; +export type { RotateParams } from './rotate-types'; diff --git a/packages/react-components/react-motion-components-preview/library/src/components/Rotate/rotate-types.ts b/packages/react-components/react-motion-components-preview/library/src/components/Rotate/rotate-types.ts new file mode 100644 index 00000000000000..3f7549eb4d512f --- /dev/null +++ b/packages/react-components/react-motion-components-preview/library/src/components/Rotate/rotate-types.ts @@ -0,0 +1,25 @@ +import type { PresenceDuration, PresenceEasing, AnimateOpacity } from '../../types'; + +type Axis3D = 'x' | 'y' | 'z'; + +export type RotateParams = PresenceDuration & + PresenceEasing & + AnimateOpacity & { + /** + * The axis of rotation: 'x', 'y', or 'z'. + * Defaults to 'y'. + */ + axis?: Axis3D; + + /** + * The starting rotation angle in degrees. + * Defaults to -90. + */ + angle?: number; + + /** + * The ending rotation angle in degrees. + * Defaults to the negation of `angle`. + */ + exitAngle?: number; + }; diff --git a/packages/react-components/react-motion-components-preview/library/src/index.ts b/packages/react-components/react-motion-components-preview/library/src/index.ts index 4d9d80dd4cd447..2928dfb826bf49 100644 --- a/packages/react-components/react-motion-components-preview/library/src/index.ts +++ b/packages/react-components/react-motion-components-preview/library/src/index.ts @@ -2,4 +2,5 @@ export { Collapse, CollapseSnappy, CollapseRelaxed, CollapseDelayed } from './co export { Fade, FadeSnappy, FadeRelaxed } from './components/Fade'; export { Scale, ScaleSnappy, ScaleRelaxed } from './components/Scale'; export { Slide, SlideSnappy, SlideRelaxed } from './components/Slide'; -export { Blur } from './components/Blur'; +export { Blur, type BlurParams } from './components/Blur'; +export { Rotate, type RotateParams } from './components/Rotate'; diff --git a/packages/react-components/react-motion-components-preview/stories/src/Rotate/RotateCardFlip.stories.tsx b/packages/react-components/react-motion-components-preview/stories/src/Rotate/RotateCardFlip.stories.tsx new file mode 100644 index 00000000000000..95009a8bc4f102 --- /dev/null +++ b/packages/react-components/react-motion-components-preview/stories/src/Rotate/RotateCardFlip.stories.tsx @@ -0,0 +1,195 @@ +import * as React from 'react'; +import { makeStyles, tokens, Button, CompoundButton, motionTokens } from '@fluentui/react-components'; +import { Rotate, type RotateParams } from '@fluentui/react-motion-components-preview'; + +const useClasses = makeStyles({ + container: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXL, // 20px + padding: tokens.spacingVerticalXL, // 20px + maxWidth: '1000px', + }, + controls: { + display: 'flex', + gap: tokens.spacingHorizontalMNudge, // 10px + alignItems: 'center', + flexWrap: 'wrap', + marginBottom: tokens.spacingVerticalXL, // 20px + }, + patternsGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', + gap: tokens.spacingVerticalXL, // 20px + }, + cardWrapper: { + perspective: '500px', + perspectiveOrigin: 'center center', + height: '140px', + cursor: 'pointer', + borderRadius: tokens.borderRadiusMedium, + transition: `scale ${motionTokens.durationSlow}ms ${motionTokens.curveDecelerateMid}`, + '&:hover': { + scale: '105%', + }, + }, + patternCard: { + height: '100%', + width: '100%', + border: `2px solid ${tokens.colorNeutralStroke1}`, + backgroundColor: tokens.colorNeutralBackground1, // Override transparent background from outline appearance + ':hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, // Override transparent hover background + }, + }, + demoIcon: { + width: '48px', + height: '48px', + borderRadius: tokens.borderRadiusMedium, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '24px', + fontWeight: 'bold', + color: tokens.colorNeutralForegroundOnBrand, + }, +}); + +const curveSpringRelaxed = `linear(0.0000 0.00%, 0.9935 36.00%, 1.042 38.00%, 1.072 40.00%, 1.084 42.00%, 1.080 44.00%, 1.055 47.00%, 0.9933 53.00%, 0.9746 57.00%, 0.9797 62.00%, 1.002 69.00%, 1.008 73.00%, 1.008 76.00%, 0.9980 87.00%, 1.000 100.00%)`; + +type RequiredRotateParams = Required< + Pick +>; + +type RotatePattern = { + id: string; + name: string; + description: string; + icon: string; + color: string; +} & RequiredRotateParams; + +const patterns: RotatePattern[] = [ + { + id: 'flip-horizontal', + name: 'Horizontal Flip', + description: 'Y-axis rotation', + icon: '↔️', + color: tokens.colorPaletteBlueForeground2, + axis: 'y', + angle: 180, + easing: curveSpringRelaxed, + exitEasing: motionTokens.curveDecelerateMid, + duration: motionTokens.durationUltraSlow * 4, // 2000ms = 500ms * 4 + exitDuration: motionTokens.durationUltraSlow, + }, + { + id: 'flip-vertical', + name: 'Vertical Flip', + description: 'X-axis rotation', + icon: '↕️', + color: tokens.colorPaletteGreenForeground2, + axis: 'x', + angle: 180, + easing: curveSpringRelaxed, + exitEasing: motionTokens.curveDecelerateMid, + duration: motionTokens.durationUltraSlow * 4, // 2000ms = 500ms * 4 + exitDuration: motionTokens.durationUltraSlow, + }, + { + id: 'spin', + name: 'Spin', + description: 'Z-axis rotation', + icon: '🔄', + color: tokens.colorPaletteRedForeground2, + axis: 'z', + angle: 180, + easing: curveSpringRelaxed, + exitEasing: motionTokens.curveDecelerateMid, + duration: motionTokens.durationUltraSlow * 4, // 2000ms = 500ms * 4 + exitDuration: motionTokens.durationUltraSlow, + }, +]; + +export const CardFlip = () => { + const classes = useClasses(); + const [activePatterns, setActivePatterns] = React.useState>(new Set(patterns.map(p => p.id))); + + const togglePattern = (patternId: string) => { + setActivePatterns(prev => { + const newSet = new Set(prev); + if (newSet.has(patternId)) { + newSet.delete(patternId); + } else { + newSet.add(patternId); + } + return newSet; + }); + }; + + const toggleAllPatterns = () => { + if (activePatterns.size === patterns.length) { + // All are showing, so hide all + setActivePatterns(new Set()); + } else { + // Some or none are showing, so show all + setActivePatterns(new Set(patterns.map(p => p.id))); + } + }; + + const getToggleButtonText = () => { + return activePatterns.size === patterns.length ? 'Flip to Back' : 'Flip to Front'; + }; + + return ( +
+
+ +
+ +
+ {patterns.map(pattern => ( +
+ + + {pattern.icon} +
+ } + onClick={() => togglePattern(pattern.id)} + secondaryContent={pattern.description} + > + {pattern.name} + + +
+ ))} +
+ + ); +}; + +CardFlip.parameters = { + docs: { + description: { + story: + 'Each card rotates around a specific axis (X, Y, or Z) with different easing and durations for the enter and exit transitions.', + }, + }, +}; diff --git a/packages/react-components/react-motion-components-preview/stories/src/Rotate/RotateDefault.stories.tsx b/packages/react-components/react-motion-components-preview/stories/src/Rotate/RotateDefault.stories.tsx new file mode 100644 index 00000000000000..54ff6fd59e243e --- /dev/null +++ b/packages/react-components/react-motion-components-preview/stories/src/Rotate/RotateDefault.stories.tsx @@ -0,0 +1,207 @@ +import * as React from 'react'; +import { + Field, + makeStyles, + tokens, + useId, + Label, + Slider, + RadioGroup, + Radio, + motionTokens, + Button, +} from '@fluentui/react-components'; +import { Rotate, type RotateParams } from '@fluentui/react-motion-components-preview'; + +type Axis3D = NonNullable; + +const useClasses = makeStyles({ + container: { + display: 'grid', + gridTemplate: `"controls ." "card card" / 1fr 1fr`, + gap: `${tokens.spacingVerticalXL} ${tokens.spacingHorizontalMNudge}`, + overflow: 'clip', + }, + card: { + gridArea: 'card', + padding: tokens.spacingHorizontalMNudge, + }, + controls: { + display: 'flex', + flexDirection: 'column', + gridArea: 'controls', + border: `${tokens.strokeWidthThicker} solid ${tokens.colorNeutralForeground3}`, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow16, + padding: tokens.spacingHorizontalL, + gap: tokens.spacingVerticalL, + }, + controlSection: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + }, + sectionHeader: { + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightSemibold, + color: tokens.colorNeutralForeground2, + marginBottom: tokens.spacingVerticalXS, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + paddingBottom: tokens.spacingVerticalXS, + }, + toggleGroup: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: tokens.spacingHorizontalM, + }, + ctaButton: { + flex: 1, + }, + field: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + }, + sliderField: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + }, + sliderWrapper: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalS, + }, + sliderLabel: { + fontSize: tokens.fontSizeBase300, + fontWeight: tokens.fontWeightMedium, + color: tokens.colorNeutralForeground1, + }, + valueDisplay: { + fontSize: tokens.fontSizeBase200, + color: tokens.colorNeutralForeground2, + fontFamily: tokens.fontFamilyMonospace, + }, + sliderHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, +}); + +const LoremIpsum = () => ( + <> + {'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. '.repeat( + 10, + )} + +); + +export const Default = (props: React.ComponentProps) => { + const classes = useClasses(); + const [visible, setVisible] = React.useState(false); + const [perspective, setPerspective] = React.useState('1000px'); + const [duration, setDuration] = React.useState(motionTokens.durationUltraSlow); // 500ms + const [axis, setAxis] = React.useState('y'); + const [angle, setEnterAngle] = React.useState(-90); + + const perspectiveSliderId = useId(); + const durationSliderId = useId(); + const enterAngleSliderId = useId(); + + const perspectiveMin = 200; + const perspectiveMax = 2000; + const durationMin = motionTokens.durationUltraFast; // 50ms + const durationMax = motionTokens.durationUltraSlow * 2; // 1000ms + const angleMin = -180; + const angleMax = 180; + + return ( +
+
+ {/* Animation Controls Section */} +
+
+ +
+
+ + {/* Rotation Settings Section */} +
+ + setAxis(data.value as Axis3D)} layout="horizontal"> + + + + + + + +
+ + {angle}° +
+ { + setEnterAngle(data.value); + }} + /> +
+
+ + {/* Timing & Perspective Section */} +
+ +
+ + {duration}ms +
+ { + setDuration(data.value); + }} + /> +
+ + +
+ + {perspective} +
+ { + setPerspective(`${data.value}px`); + }} + /> +
+
+
+ + +
+ +
+
+
+ ); +}; diff --git a/packages/react-components/react-motion-components-preview/stories/src/Rotate/RotateDescription.md b/packages/react-components/react-motion-components-preview/stories/src/Rotate/RotateDescription.md new file mode 100644 index 00000000000000..070b9563984947 --- /dev/null +++ b/packages/react-components/react-motion-components-preview/stories/src/Rotate/RotateDescription.md @@ -0,0 +1,15 @@ +The Rotate component provides 3D rotation animations with independent X, Y, and Z axis control. + +> **⚠️ Preview components are considered unstable** + +```tsx +import { Rotate } from '@fluentui/react-motion-components-preview'; + +function Component({ visible }) { + return ( + +
Content
+
+ ); +} +``` diff --git a/packages/react-components/react-motion-components-preview/stories/src/Rotate/index.stories.ts b/packages/react-components/react-motion-components-preview/stories/src/Rotate/index.stories.ts new file mode 100644 index 00000000000000..18256d253a3a50 --- /dev/null +++ b/packages/react-components/react-motion-components-preview/stories/src/Rotate/index.stories.ts @@ -0,0 +1,17 @@ +import { Default as Rotate } from './RotateDefault.stories'; +import RotateDescription from './RotateDescription.md'; + +export { Default } from './RotateDefault.stories'; +export { CardFlip } from './RotateCardFlip.stories'; + +export default { + title: 'Motion/Components (preview)/Rotate', + component: Rotate, + parameters: { + docs: { + description: { + component: RotateDescription, + }, + }, + }, +};