Remove framer-motion from twenty-ui (#22021)

## What

Removes the `framer-motion` dependency from `twenty-ui` and replaces
every usage with pure CSS animations, reaching for Base UI primitives
where one fits:

- **Collapse/expand** (`AnimatedEaseInOut`,
`AnimatedExpandableContainer`): rebuilt on Base UI `Collapsible`
(CSS-animated `--collapsible-panel-height/width` + transition states).
Public props unchanged, so the ~28 call sites are untouched.
- **ProgressBar**: rebuilt on Base UI `Progress` (proper
`role`/`aria-valuenow`). The snackbar auto-dismiss countdown now uses a
CSS keyframe + `animation-play-state` (pause on hover), removing a
per-frame React re-render; `useProgressAnimation` is deleted.
- The remaining `Animated*` components, the circular spinner, checkmark,
and the placeholder pointer parallax move to plain CSS (SCSS modules +
the `duration()` helper + theme tokens).
- Deletes 3 unused components (`AnimatedTranslation`,
`AnimatedTextWord`, `AnimatedFadeOut`).

## Why

`twenty-ui` is a publicly published library with a size budget, so
dropping framer-motion shrinks what consumers ship. `twenty-front` keeps
its own framer-motion; that is out of scope here.

## Notes for reviewers

- A few `twenty-ui` components received framer props from `twenty-front`
call sites; those were migrated (e.g. `AnimatedLightIconButton` gained a
CSS `rotate` prop, and the `EMPTY_PLACEHOLDER_TRANSITION_PROPS` spreads
were removed).
- Behavior change: Base UI `Collapsible` animates only on open/close
transitions, so the old "animate in on first mount while already open"
case no longer plays (the `initial` prop is kept for API compatibility).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22021?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-06-23 17:43:08 +02:00
committed by GitHub
parent d4e4e2612b
commit 766d90af7e
67 changed files with 575 additions and 829 deletions
@@ -1,11 +1,10 @@
import { styled } from '@linaria/react';
import { differenceInSeconds, endOfDay, format } from 'date-fns';
import { useContext } from 'react';
import { CalendarEventRow } from '@/activities/calendar/components/CalendarEventRow';
import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate';
import { CardContent } from 'twenty-ui/surfaces';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type TimelineCalendarEvent } from '~/generated/graphql';
type CalendarDayCardContentProps = {
@@ -23,6 +22,17 @@ const StyledCardContentContainer = styled.div`
}
`;
const StyledDayCardContent = styled(CardContent)`
@keyframes calendarDayEnded {
to {
background-color: ${themeCssVariables.background.primary};
}
}
animation: calendarDayEnded calc(var(--t-animation-duration-fast) * 1s) ease
forwards;
`;
const StyledDayContainer = styled.div`
text-align: center;
width: ${themeCssVariables.spacing[6]};
@@ -54,31 +64,17 @@ export const CalendarDayCardContent = ({
calendarEvents,
divider,
}: CalendarDayCardContentProps) => {
const { theme } = useContext(ThemeContext);
const endOfDayDate = endOfDay(getCalendarEventStartDate(calendarEvents[0]));
const dayEndsIn = differenceInSeconds(endOfDayDate, Date.now());
const weekDayLabel = format(endOfDayDate, 'EE');
const monthDayLabel = format(endOfDayDate, 'dd');
const upcomingDayCardContentVariants = {
upcoming: {},
ended: {
backgroundColor: theme.background.primary,
},
};
return (
<StyledCardContentContainer>
<CardContent
<StyledDayCardContent
divider={divider}
initial="upcoming"
animate="ended"
variants={upcomingDayCardContentVariants}
transition={{
delay: Math.max(0, dayEndsIn),
duration: theme.animation.duration.fast,
}}
style={{ animationDelay: `${Math.max(0, dayEndsIn)}s` }}
>
<StyledDayContainer>
<StyledWeekDay>{weekDayLabel}</StyledWeekDay>
@@ -91,7 +87,7 @@ export const CalendarDayCardContent = ({
</StyledEventRowContainer>
))}
</StyledEvents>
</CardContent>
</StyledDayCardContent>
</StyledCardContentContainer>
);
};
@@ -20,7 +20,6 @@ import {
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/feedback';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -97,10 +96,7 @@ export const CalendarEventsCard = () => {
if (!firstQueryLoading && !timelineCalendarEvents?.length) {
// TODO: change animated placeholder
return (
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholderEmptyContainer>
<AnimatedPlaceholder type="noMatchRecord" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
@@ -8,7 +8,6 @@ import {
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/feedback';
export const EmptyInboxPlaceholder = () => {
@@ -16,10 +15,7 @@ export const EmptyInboxPlaceholder = () => {
const { openComposer, loading } = useComposeEmailForTargetRecord();
return (
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholderEmptyContainer>
<AnimatedPlaceholder type="emptyInbox" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
@@ -20,7 +20,6 @@ import {
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/feedback';
import { PermissionFlagType } from '~/generated-metadata/graphql';
@@ -103,10 +102,7 @@ export const FilesCard = () => {
onUploadFiles={onUploadFiles}
/>
) : (
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholderEmptyContainer>
<AnimatedPlaceholder type="noFile" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
@@ -17,7 +17,6 @@ import {
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/feedback';
const StyledNotesContainer = styled.div`
@@ -61,10 +60,7 @@ export const NotesCard = () => {
if (isNotesEmpty) {
return (
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholderEmptyContainer>
<AnimatedPlaceholder type="noNote" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
@@ -20,7 +20,6 @@ import {
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/feedback';
import { AddTaskButton } from './AddTaskButton';
import { TaskList } from './TaskList';
@@ -71,10 +70,7 @@ export const TaskGroups = ({ targetableObject }: TaskGroupsProps) => {
if (isTasksEmpty) {
return (
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholderEmptyContainer>
<AnimatedPlaceholder type="noTask" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
@@ -13,7 +13,6 @@ import {
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/feedback';
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
@@ -63,10 +62,7 @@ export const TimelineCard = () => {
if (isTimelineActivitiesEmpty) {
const placeholderContent = (
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholderEmptyContainer>
<AnimatedPlaceholder type="emptyTimeline" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
@@ -1,6 +1,6 @@
import { styled } from '@linaria/react';
import { useCallback, useContext } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useCallback } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { RecordBoardColumnHeaderAggregateDropdown } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown';
import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector';
@@ -114,8 +114,6 @@ const StyledRecordTableDragAndDropPlaceholderCell = styled.div`
`;
export const RecordTableRecordGroupSection = () => {
const { theme } = useContext(ThemeContext);
const currentRecordGroupId = useCurrentRecordGroupId();
const shouldHide = useShouldHideRecordGroup(currentRecordGroupId);
@@ -199,8 +197,7 @@ export const RecordTableRecordGroupSection = () => {
Icon={IconChevronDown}
size="small"
accent="secondary"
animate={{ rotate: !isRecordGroupTableSectionToggled ? -90 : 0 }}
transition={{ duration: theme.animation.duration.normal }}
rotate={!isRecordGroupTableSectionToggled ? -90 : 0}
/>
</StyledAnimatedLightIconButtonContainer>
</StyledChevronContainer>
@@ -16,7 +16,6 @@ import {
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/feedback';
export const DashboardWidgetPlaceholder = () => {
@@ -66,10 +65,7 @@ export const DashboardWidgetPlaceholder = () => {
title={t`Add Widget`}
isEmpty
/>
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholderEmptyContainer>
<AnimatedPlaceholder type="noWidgets" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
@@ -7,7 +7,6 @@ import {
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/feedback';
const StyledPlaceholderContainer = styled.div`
@@ -24,10 +23,7 @@ const StyledPlaceholderContainer = styled.div`
export const StandaloneWidgetPlaceholder = () => {
return (
<StyledPlaceholderContainer className="widget">
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholderEmptyContainer>
<AnimatedPlaceholder type="noWidgets" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
@@ -35,7 +35,6 @@ import {
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/feedback';
import { FieldDisplayMode } from '~/generated-metadata/graphql';
@@ -76,10 +75,7 @@ export const FieldWidget = ({ widget }: FieldWidgetProps) => {
return (
<SidePanelProvider value={{ isInSidePanel }}>
<StyledContainer>
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholderEmptyContainer>
<AnimatedPlaceholder type="noRecord" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
@@ -19,7 +19,6 @@ import {
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/feedback';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type FieldsConfiguration } from '~/generated-metadata/graphql';
@@ -108,10 +107,7 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => {
return (
<SidePanelProvider value={{ isInSidePanel }}>
<StyledContainer>
<AnimatedPlaceholderEmptyContainer
// oxlint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholderEmptyContainer>
<AnimatedPlaceholder type="noRecord" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
@@ -8,6 +8,7 @@ import {
type ReactNode,
useContext,
useMemo,
useState,
} from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
@@ -17,7 +18,7 @@ import {
IconX,
} from 'twenty-ui/icon';
import { HorizontalSeparator } from 'twenty-ui/layout';
import { ProgressBar, useProgressAnimation } from 'twenty-ui/feedback';
import { ProgressBar } from 'twenty-ui/feedback';
import { LightButton, LightIconButton } from 'twenty-ui/input';
import { UndecoratedLink } from 'twenty-ui/navigation';
import {
@@ -156,15 +157,8 @@ export const SnackBar = ({
}: SnackBarProps) => {
const { i18n, t } = useLingui();
const { theme } = useContext(ThemeContext);
const { animation: progressAnimation, value: progressValue } =
useProgressAnimation({
autoPlay: isUndefined(overrideProgressValue),
initialValue: isDefined(overrideProgressValue)
? overrideProgressValue
: 100,
finalValue: 0,
options: { duration, onComplete: onClose },
});
const [isPaused, setIsPaused] = useState(false);
const isAutoDismiss = isUndefined(overrideProgressValue);
const icon = useMemo(() => {
if (isDefined(iconComponent)) {
@@ -200,11 +194,11 @@ export const SnackBar = ({
}, [iconComponent, variant, i18n, theme.icon.size.md, theme.snackBar]);
const handleMouseEnter = () => {
progressAnimation?.pause();
setIsPaused(true);
};
const handleMouseLeave = () => {
progressAnimation?.play();
setIsPaused(false);
};
const sanitizedMessage = sanitizeMessageToRenderInSnackbar(message);
@@ -225,7 +219,10 @@ export const SnackBar = ({
<StyledProgressBarContainer>
<ProgressBar
barColor={theme.snackBar[variant].backgroundColor}
value={progressValue}
value={overrideProgressValue ?? 100}
countdownDurationInMs={isAutoDismiss ? duration : undefined}
isCountdownPaused={isPaused}
onCountdownComplete={onClose}
/>
</StyledProgressBarContainer>
<StyledHeader>
@@ -194,7 +194,7 @@ export const WorkflowOutputSchemaBuilder = ({
<AnimatedLightIconButton
Icon={IconChevronDown}
size="small"
animate={{ rotate: isExpanded ? -180 : 0 }}
rotate={isExpanded ? -180 : 0}
/>
{showRemoveFieldButton && (
<LightIconButton
-2
View File
@@ -77,8 +77,6 @@
"@sniptt/guards": "^0.2.0",
"@tabler/icons-react": "^3.31.0",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"framer-motion": "^11.18.0",
"react-responsive": "^9.0.2",
"react-router-dom": "^6.4.4",
"type-fest": "^4.10.1",
@@ -0,0 +1,14 @@
.animatedPath {
animation: draw-checkmark var(--animated-checkmark-duration, 0.5s) ease
forwards;
}
@keyframes draw-checkmark {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
@@ -0,0 +1,4 @@
declare const classNames: {
readonly animatedPath: 'animatedPath';
};
export default classNames;
@@ -1,9 +1,8 @@
import { motion } from 'framer-motion';
import { useTheme } from '@ui/theme-constants';
export type AnimatedCheckmarkProps = React.ComponentProps<
typeof motion.path
> & {
import styles from './AnimatedCheckmark.module.scss';
export type AnimatedCheckmarkProps = {
isAnimating?: boolean;
color?: string;
duration?: number;
@@ -25,16 +24,20 @@ export const AnimatedCheckmark = ({
width={size}
height={size}
>
<motion.path
<path
className={isAnimating ? styles.animatedPath : undefined}
fill="none"
stroke={color ?? theme.grayScale.gray1}
strokeWidth={4}
d="M14 27l7.8 7.8L38 14"
pathLength="1"
strokeDasharray="1"
strokeDashoffset={isAnimating ? '1' : '0'}
animate={{ strokeDashoffset: isAnimating ? '0' : '1' }}
transition={{ duration }}
strokeDashoffset={isAnimating ? 0 : 1}
style={
{
'--animated-checkmark-duration': `${duration}s`,
} as React.CSSProperties
}
/>
</svg>
);
@@ -16,3 +16,17 @@
max-height: 245px;
max-width: 245px;
}
.movingImage {
position: absolute;
z-index: 2;
max-width: 130px;
max-height: 130px;
transform: translate(var(--parallax-x, 0), var(--parallax-y, 0));
transition: transform duration(normal) ease-out;
}
.movingImageLarge {
max-width: 185px;
max-height: 185px;
}
@@ -2,5 +2,7 @@ declare const classNames: {
readonly container: 'container';
readonly backgroundImage: 'backgroundImage';
readonly backgroundImageLarge: 'backgroundImageLarge';
readonly movingImage: 'movingImage';
readonly movingImageLarge: 'movingImageLarge';
};
export default classNames;
@@ -1,6 +1,5 @@
import { clsx } from 'clsx';
import { animate, motion, useMotionValue, useTransform } from 'framer-motion';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { BACKGROUND } from '@ui/feedback/AnimatedPlaceholder/constants/Background';
import { DARK_BACKGROUND } from '@ui/feedback/AnimatedPlaceholder/constants/DarkBackground';
@@ -10,8 +9,7 @@ import { useThemeColorScheme } from '@ui/theme-constants';
import styles from './AnimatedPlaceholder.module.scss';
const getMovingImageSize = (type: string) =>
type === 'error500' || type === 'error404' ? '185px' : '130px';
const PARALLAX_OFFSET_IN_PX = 2;
export type AnimatedPlaceholderType =
| keyof typeof BACKGROUND
@@ -23,35 +21,35 @@ type AnimatedPlaceholderProps = {
export const AnimatedPlaceholder = ({ type }: AnimatedPlaceholderProps) => {
const colorScheme = useThemeColorScheme();
const x = useMotionValue(window.innerWidth / 2);
const y = useMotionValue(window.innerHeight / 2);
const translateX = useTransform(x, [0, window.innerWidth], [-2, 2]);
const translateY = useTransform(y, [0, window.innerHeight], [-2, 2]);
const movingImageRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const movingImage = movingImageRef.current;
if (movingImage === null) {
return;
}
const setParallax = (offsetX: number, offsetY: number) => {
movingImage.style.setProperty('--parallax-x', `${offsetX}px`);
movingImage.style.setProperty('--parallax-y', `${offsetY}px`);
};
const handleMove = (event: MouseEvent | TouchEvent) => {
const clientX =
'touches' in event ? event.touches[0].clientX : event.clientX;
const clientY =
'touches' in event ? event.touches[0].clientY : event.clientY;
x.set(clientX);
y.set(clientY);
setParallax(
(clientX / window.innerWidth) * 2 * PARALLAX_OFFSET_IN_PX -
PARALLAX_OFFSET_IN_PX,
(clientY / window.innerHeight) * 2 * PARALLAX_OFFSET_IN_PX -
PARALLAX_OFFSET_IN_PX,
);
};
const handleLeave = () => {
animate(x, window.innerWidth / 2, {
type: 'spring',
stiffness: 100,
damping: 10,
});
animate(y, window.innerHeight / 2, {
type: 'spring',
stiffness: 100,
damping: 10,
});
setParallax(0, 0);
};
window.addEventListener('mousemove', handleMove);
@@ -63,7 +61,9 @@ export const AnimatedPlaceholder = ({ type }: AnimatedPlaceholderProps) => {
window.removeEventListener('touchmove', handleMove);
window.document.removeEventListener('mouseleave', handleLeave);
};
}, [x, y]);
}, []);
const isLarge = type === 'error500' || type === 'error404';
return (
<div className={styles.container}>
@@ -72,24 +72,16 @@ export const AnimatedPlaceholder = ({ type }: AnimatedPlaceholderProps) => {
alt=""
className={clsx(
styles.backgroundImage,
(type === 'error500' || type === 'error404') &&
styles.backgroundImageLarge,
isLarge && styles.backgroundImageLarge,
)}
/>
<motion.img
<img
ref={movingImageRef}
src={
colorScheme === 'dark' ? DARK_MOVING_IMAGE[type] : MOVING_IMAGE[type]
}
alt=""
style={{
position: 'absolute',
maxWidth: getMovingImageSize(type),
maxHeight: getMovingImageSize(type),
zIndex: 2,
translateX,
translateY,
}}
transition={{ type: 'spring', stiffness: 100, damping: 10 }}
className={clsx(styles.movingImage, isLarge && styles.movingImageLarge)}
/>
</div>
);
@@ -0,0 +1,40 @@
.svg {
animation: circularProgressBarRotate 2s linear infinite;
transform-origin: center;
}
.circle {
animation: circularProgressBarDash 2s linear infinite;
}
@keyframes circularProgressBarRotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(720deg);
}
}
@keyframes circularProgressBarDash {
0% {
stroke-dasharray: 10 90;
}
25% {
stroke-dasharray: 20 80;
}
50% {
stroke-dasharray: 30 70;
}
75% {
stroke-dasharray: 20 80;
}
100% {
stroke-dasharray: 10 90;
}
}
@@ -0,0 +1,5 @@
declare const classNames: {
readonly svg: 'svg';
readonly circle: 'circle';
};
export default classNames;
@@ -1,5 +1,4 @@
import { motion, useAnimation } from 'framer-motion';
import { useEffect, useMemo } from 'react';
import styles from './CircularProgressBar.module.scss';
type CircularProgressBarProps = {
size?: number;
@@ -11,61 +10,18 @@ export const CircularProgressBar = ({
size = 50,
barWidth = 5,
barColor = 'currentColor',
}: CircularProgressBarProps) => {
const controls = useAnimation();
const circumference = useMemo(
() => 2 * Math.PI * (size / 2 - barWidth),
[size, barWidth],
);
useEffect(() => {
const animateIndeterminate = async () => {
const baseSegment = Math.max(5, circumference / 10); // Adjusting for smaller values
// Adjusted sequence based on baseSegment
const dashSequences = [
`${baseSegment} ${circumference - baseSegment}`,
`${baseSegment * 2} ${circumference - baseSegment * 2}`,
`${baseSegment * 3} ${circumference - baseSegment * 3}`,
`${baseSegment * 2} ${circumference - baseSegment * 2}`,
`${baseSegment} ${circumference - baseSegment}`,
];
await controls.start({
strokeDasharray: dashSequences,
rotate: [0, 720],
transition: {
strokeDasharray: {
duration: 2,
ease: 'linear',
repeat: Infinity,
repeatType: 'loop',
},
rotate: {
duration: 2,
ease: 'linear',
repeat: Infinity,
repeatType: 'loop',
},
},
});
};
animateIndeterminate();
}, [circumference, controls]);
return (
<motion.svg width={size} height={size} animate={controls}>
<motion.circle
cx={size / 2}
cy={size / 2}
r={size / 2 - barWidth}
fill="none"
stroke={barColor}
strokeWidth={barWidth}
strokeLinecap="round"
/>
</motion.svg>
);
};
}: CircularProgressBarProps) => (
<svg className={styles.svg} width={size} height={size}>
<circle
className={styles.circle}
cx={size / 2}
cy={size / 2}
r={size / 2 - barWidth}
fill="none"
stroke={barColor}
strokeWidth={barWidth}
strokeLinecap="round"
pathLength={100}
/>
</svg>
);
@@ -7,6 +7,17 @@
gap: var(--t-spacing-6);
justify-content: center;
text-align: center;
animation: emptyPlaceholderFadeIn duration(fast) ease;
}
@keyframes emptyPlaceholderFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.emptyTextContainer {
@@ -1,14 +1,10 @@
import { clsx } from 'clsx';
import { type HTMLMotionProps, motion } from 'framer-motion';
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { isDefined } from '@ui/utilities/utils/isDefined';
import styles from './EmptyPlaceholderStyled.module.scss';
type AnimatedPlaceholderEmptyContainerProps = Pick<
HTMLMotionProps<'div'>,
'initial' | 'animate' | 'transition'
> & {
type AnimatedPlaceholderEmptyContainerProps = {
children?: ReactNode;
className?: string;
width?: number;
@@ -18,31 +14,17 @@ export const AnimatedPlaceholderEmptyContainer = ({
children,
className,
width,
initial,
animate,
transition,
}: AnimatedPlaceholderEmptyContainerProps) => {
return (
<motion.div
<div
className={clsx(styles.emptyContainer, className)}
style={isDefined(width) ? { width: `${width}px` } : undefined}
initial={initial}
animate={animate}
transition={transition}
>
{children}
</motion.div>
</div>
);
};
export const EMPTY_PLACEHOLDER_TRANSITION_PROPS = {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: {
duration: 0.15,
},
};
export const AnimatedPlaceholderEmptyTextContainer = ({
className,
...rest
@@ -1,22 +1,52 @@
.bar {
position: relative;
height: var(--t-spacing-2);
width: 100%;
background-color: var(--progress-bar-background-color, none);
border-radius: 0;
overflow: hidden;
width: 100%;
&[data-with-border-radius] {
border-radius: var(--t-border-radius-xxl);
}
}
.barFilling {
background-color: var(--progress-bar-color, var(--t-font-color-primary));
border-radius: 0;
.track {
position: relative;
height: 100%;
width: 100%;
}
.indicator {
height: 100%;
background-color: var(--progress-bar-color, var(--t-font-color-primary));
border-radius: 0;
transition: width duration(normal) linear;
&[data-with-border-radius] {
border-radius: var(--t-border-radius-md);
}
&[data-nonzero] {
min-width: 12px;
}
}
.countdown {
animation: progressBarCountdown
var(--progress-bar-countdown-duration, #{duration(normal)}) linear forwards;
&[data-paused] {
animation-play-state: paused;
}
}
@keyframes progressBarCountdown {
from {
width: 100%;
}
to {
width: 0;
}
}
@@ -1,5 +1,7 @@
declare const classNames: {
readonly bar: 'bar';
readonly barFilling: 'barFilling';
readonly track: 'track';
readonly indicator: 'indicator';
readonly countdown: 'countdown';
};
export default classNames;
@@ -1,3 +1,4 @@
import { Progress } from '@base-ui/react/progress';
import { clsx } from 'clsx';
import styles from './ProgressBar.module.scss';
@@ -9,16 +10,11 @@ export type ProgressBarProps = {
backgroundColor?: string;
withBorderRadius?: boolean;
ariaLabel?: string;
countdownDurationInMs?: number;
isCountdownPaused?: boolean;
onCountdownComplete?: () => void;
};
export type StyledBarProps = {
className?: string;
backgroundColor?: string;
withBorderRadius?: boolean;
};
const MIN_BAR_WIDTH_PX = 12;
export const ProgressBar = ({
value,
className,
@@ -26,36 +22,39 @@ export const ProgressBar = ({
backgroundColor = 'none',
withBorderRadius = false,
ariaLabel,
}: ProgressBarProps) => (
<div
className={clsx(styles.bar, className)}
data-with-border-radius={withBorderRadius || undefined}
role="progressbar"
aria-label={ariaLabel}
aria-valuenow={Math.ceil(value)}
style={
{
'--progress-bar-background-color': backgroundColor,
} as React.CSSProperties
}
>
<div
style={{
height: '100%',
minWidth: value > 0 ? MIN_BAR_WIDTH_PX : 0,
width: `${Math.ceil(value)}%`,
transition: 'width 0.3s linear',
}}
countdownDurationInMs,
isCountdownPaused = false,
onCountdownComplete,
}: ProgressBarProps) => {
const isCountdown = countdownDurationInMs !== undefined;
return (
<Progress.Root
className={clsx(styles.bar, className)}
data-with-border-radius={withBorderRadius || undefined}
aria-label={ariaLabel}
value={value}
style={
{
'--progress-bar-background-color': backgroundColor,
...(barColor ? { '--progress-bar-color': barColor } : {}),
...(isCountdown
? {
'--progress-bar-countdown-duration': `${countdownDurationInMs}ms`,
}
: {}),
} as React.CSSProperties
}
>
<div
className={styles.barFilling}
data-with-border-radius={withBorderRadius || undefined}
style={
barColor
? ({ '--progress-bar-color': barColor } as React.CSSProperties)
: undefined
}
/>
</div>
</div>
);
<Progress.Track className={styles.track}>
<Progress.Indicator
className={clsx(styles.indicator, isCountdown && styles.countdown)}
data-with-border-radius={withBorderRadius || undefined}
data-nonzero={(value > 0 && !isCountdown) || undefined}
data-paused={(isCountdown && isCountdownPaused) || undefined}
onAnimationEnd={isCountdown ? onCountdownComplete : undefined}
/>
</Progress.Track>
</Progress.Root>
);
};
@@ -1,5 +1,4 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { useProgressAnimation } from '@ui/feedback/ProgressBar/hooks/useProgressAnimation';
import { ComponentDecorator } from '@ui/testing/decorators/ComponentDecorator';
import { ProgressBar } from '@ui/feedback/ProgressBar/ProgressBar';
@@ -24,25 +23,16 @@ export const Default: Story = {
},
};
export const Animated: Story = {
export const Countdown: Story = {
tags: ['!test'],
argTypes: {
value: { control: false },
},
decorators: [
(Story) => {
const { value } = useProgressAnimation({
autoPlay: true,
initialValue: 0,
finalValue: 100,
options: {
duration: 10000,
},
});
return <Story args={{ value }} />;
},
],
args: {
value: 100,
ariaLabel: 'Progress',
countdownDurationInMs: 10000,
},
parameters: {
chromatic: { disableSnapshot: true },
},
@@ -1,57 +0,0 @@
import { millisecondsToSeconds } from 'date-fns';
import {
animate,
type AnimationPlaybackControls,
type ValueAnimationTransition,
} from 'framer-motion';
import { useCallback, useEffect, useState } from 'react';
import { isDefined } from '@ui/utilities/utils/isDefined';
export const useProgressAnimation = ({
autoPlay = true,
initialValue = 0,
finalValue = 100,
options,
}: {
autoPlay?: boolean;
initialValue?: number;
finalValue?: number;
options?: ValueAnimationTransition<number>;
}) => {
const [animation, setAnimation] = useState<
AnimationPlaybackControls | undefined
>();
const [value, setValue] = useState(initialValue);
const startAnimation = useCallback(() => {
if (isDefined(animation)) return;
const duration = isDefined(options?.duration)
? millisecondsToSeconds(options.duration)
: undefined;
setAnimation(
animate(initialValue, finalValue, {
...options,
duration,
onUpdate: (nextValue) => {
if (value === nextValue) return;
setValue(nextValue);
options?.onUpdate?.(nextValue);
},
}),
);
}, [animation, finalValue, initialValue, options, value]);
useEffect(() => {
if (autoPlay && !animation) {
startAnimation();
}
}, [animation, autoPlay, startAnimation]);
return {
animation,
startAnimation,
value,
};
};
+1 -6
View File
@@ -20,7 +20,6 @@ export { Callout } from './Callout/Callout';
export { CircularProgressBar } from './CircularProgressBar/CircularProgressBar';
export {
AnimatedPlaceholderEmptyContainer,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
AnimatedPlaceholderEmptySubTitle,
@@ -35,11 +34,7 @@ export type { InfoAccent, InfoProps } from './Info/Info';
export { Info } from './Info/Info';
export { InlineBanner } from './InlineBanner/InlineBanner';
export { Loader } from './Loader/Loader';
export { useProgressAnimation } from './ProgressBar/hooks/useProgressAnimation';
export type {
ProgressBarProps,
StyledBarProps,
} from './ProgressBar/ProgressBar';
export type { ProgressBarProps } from './ProgressBar/ProgressBar';
export { ProgressBar } from './ProgressBar/ProgressBar';
export type { SidePanelInformationBannerProps } from './SidePanelInformationBanner/SidePanelInformationBanner';
export { SidePanelInformationBanner } from './SidePanelInformationBanner/SidePanelInformationBanner';
@@ -1,5 +1,4 @@
import { clsx } from 'clsx';
import { type MotionProps, motion } from 'framer-motion';
import React, { useMemo } from 'react';
import { Link } from 'react-router-dom';
@@ -17,13 +16,12 @@ import {
import styles from './AnimatedButton.module.scss';
export type AnimatedButtonProps = ButtonProps &
Pick<MotionProps, 'animate' | 'transition'> & {
animatedSvg: React.ReactNode;
soonLabel?: string;
// Renders a square icon-only button (width matches the size-based height).
square?: boolean;
};
export type AnimatedButtonProps = ButtonProps & {
animatedSvg: React.ReactNode;
soonLabel?: string;
// Renders a square icon-only button (width matches the size-based height).
square?: boolean;
};
type AnimatedButtonDynamicStyles = {
background: string;
@@ -329,8 +327,6 @@ export const AnimatedButton = ({
dataTestId,
hotkeys,
ariaLabel,
animate,
transition,
dataClickOutsideId,
dataGloballyPreventClickOutside,
soonLabel = 'Soon',
@@ -392,23 +388,11 @@ export const AnimatedButton = ({
data-globally-prevent-click-outside={dataGloballyPreventClickOutside}
>
{Icon && (
<motion.div
className={styles.motion}
animate={animate}
transition={transition}
>
<div className={styles.motion}>
<Icon size={theme.icon.size.sm} aria-hidden />
</motion.div>
)}
{animatedSvg && (
<motion.div
className={styles.motion}
animate={animate}
transition={transition}
>
{animatedSvg}
</motion.div>
</div>
)}
{animatedSvg && <div className={styles.motion}>{animatedSvg}</div>}
{title}
{hotkeys && !isMobile && (
<>
@@ -76,4 +76,5 @@
align-items: center;
display: flex;
justify-content: center;
transition: transform duration(normal) ease;
}
@@ -1,5 +1,4 @@
import { clsx } from 'clsx';
import { motion, type MotionProps } from 'framer-motion';
import { type ComponentProps, type MouseEvent } from 'react';
import { type IconComponent } from '@ui/icon';
@@ -21,22 +20,21 @@ export type AnimatedLightIconButtonProps = {
active?: boolean;
disabled?: boolean;
focus?: boolean;
rotate?: number;
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
} & Pick<ComponentProps<'button'>, 'aria-label' | 'title'> &
Pick<MotionProps, 'animate' | 'transition'>;
} & Pick<ComponentProps<'button'>, 'aria-label' | 'title'>;
export const AnimatedLightIconButton = ({
'aria-label': ariaLabel,
className,
testId,
animate,
transition,
Icon,
active = false,
size = 'small',
accent = 'secondary',
disabled = false,
focus = false,
rotate,
onClick,
title,
}: AnimatedLightIconButtonProps) => {
@@ -55,10 +53,13 @@ export const AnimatedLightIconButton = ({
className={clsx(styles.button, styles[size], className)}
title={title}
>
<motion.div
<div
className={styles.iconContainer}
animate={animate}
transition={transition}
style={
rotate !== undefined
? { transform: `rotate(${rotate}deg)` }
: undefined
}
>
{Icon && (
<Icon
@@ -66,7 +67,7 @@ export const AnimatedLightIconButton = ({
aria-hidden={!!ariaLabel}
/>
)}
</motion.div>
</div>
</button>
);
};
@@ -28,13 +28,9 @@ export const Default: Story = {
active: false,
focus: false,
Icon: IconSearch,
animate: { scale: 1.2 },
transition: { duration: 0.3 },
},
argTypes: {
Icon: { control: false },
animate: { control: 'object' },
transition: { control: 'object' },
},
decorators: [ComponentDecorator],
};
@@ -43,16 +39,12 @@ export const Catalog: CatalogStory<Story, typeof AnimatedLightIconButton> = {
args: {
title: 'Filter',
Icon: IconSearch,
animate: { scale: 1.2 },
transition: { duration: 0.3 },
},
argTypes: {
accent: { control: false },
disabled: { control: false },
active: { control: false },
focus: { control: false },
animate: { control: 'object' },
transition: { control: 'object' },
},
parameters: {
a11y: A11Y_DEFER_COLOR_CONTRAST,
@@ -1,7 +1,6 @@
import React from 'react';
import { clsx } from 'clsx';
import { type AnimationControls } from 'framer-motion';
import { handleClickableElementKeyDown } from '@ui/accessibility/utils/handleClickableElementKeyDown';
import { Checkmark } from '@ui/data-display/Checkmark/Checkmark';
@@ -12,11 +11,8 @@ import { isDefined } from '@ui/utilities/utils/isDefined';
import styles from './ColorSchemeCard.module.scss';
// controls is no longer used since the hover animation is CSS-only now, but it
// stays in the exported type for backward-compatible public API parity.
export type ColorSchemeSegmentProps = {
variant: ColorScheme;
controls: AnimationControls;
className?: string;
} & React.ComponentPropsWithoutRef<'div'>;
@@ -27,7 +23,7 @@ const ColorSchemeSegment = ({
onClick,
onMouseEnter,
onMouseLeave,
}: Omit<ColorSchemeSegmentProps, 'controls'>) => {
}: ColorSchemeSegmentProps) => {
const grayScale = variant === 'Dark' ? GRAY_SCALE_DARK : GRAY_SCALE_LIGHT;
return (
@@ -2,4 +2,15 @@
align-items: center;
display: flex;
justify-content: center;
animation: animatedCircleLoadingSpin duration(slow) ease-in-out infinite;
}
@keyframes animatedCircleLoadingSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@@ -1,31 +1,7 @@
import { motion } from 'framer-motion';
import React from 'react';
import { useTheme } from '@ui/theme-constants';
import styles from './AnimatedCircleLoading.module.scss';
export const AnimatedCircleLoading = ({
children,
}: {
children: React.ReactNode;
}) => {
const theme = useTheme();
return (
<motion.div
className={styles.container}
initial={{ rotate: 0 }}
animate={{
rotate: 360,
}}
transition={{
repeat: Infinity,
duration: theme.animation.duration.slow,
ease: 'easeInOut',
}}
>
{children}
</motion.div>
);
};
}) => <div className={styles.container}>{children}</div>;
@@ -0,0 +1,18 @@
.container {
animation: animatedContainerFadeIn duration(fast) ease;
transition: transform duration(fast) ease;
&:hover {
transform: scale(1.04);
}
}
@keyframes animatedContainerFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@@ -1,5 +1,4 @@
declare const classNames: {
readonly container: 'container';
readonly word: 'word';
};
export default classNames;
@@ -1,17 +1,7 @@
import { motion } from 'framer-motion';
import React from 'react';
import styles from './AnimatedContainer.module.scss';
export const AnimatedContainer = ({
children,
}: {
children: React.ReactNode;
}) => (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1 }}
whileHover={{ scale: 1.04 }}
>
{children}
</motion.div>
);
}) => <div className={styles.container}>{children}</div>;
@@ -0,0 +1,27 @@
.fadeIn {
--animated-ease-in-duration: #{duration(normal)};
animation: animatedEaseInFadeIn var(--animated-ease-in-duration) linear;
&[data-duration='instant'] {
--animated-ease-in-duration: #{duration(instant)};
}
&[data-duration='fast'] {
--animated-ease-in-duration: #{duration(fast)};
}
&[data-duration='slow'] {
--animated-ease-in-duration: #{duration(slow)};
}
}
@keyframes animatedEaseInFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@@ -0,0 +1,4 @@
declare const classNames: {
readonly fadeIn: 'fadeIn';
};
export default classNames;
@@ -1,30 +1,17 @@
import { motion } from 'framer-motion';
import { type AnimationDuration } from '@ui/theme';
import { useTheme } from '@ui/theme-constants';
type AnimatedEaseInProps = Omit<
React.ComponentProps<typeof motion.div>,
'initial' | 'animated' | 'transition'
> & {
import styles from './AnimatedEaseIn.module.scss';
type AnimatedEaseInProps = {
children?: React.ReactNode;
duration?: AnimationDuration;
};
export const AnimatedEaseIn = ({
children,
duration = 'normal',
}: AnimatedEaseInProps) => {
const theme = useTheme();
const initial = { opacity: 0 };
const animate = { opacity: 1 };
const transition = {
ease: 'linear',
duration: theme.animation.duration[duration],
};
return (
<motion.div initial={initial} animate={animate} transition={transition}>
{children}
</motion.div>
);
};
}: AnimatedEaseInProps) => (
<div className={styles.fadeIn} data-duration={duration}>
{children}
</div>
);
@@ -0,0 +1,37 @@
.panel {
--animated-ease-in-out-duration: #{duration(normal)};
overflow: hidden;
opacity: 1;
height: var(--collapsible-panel-height);
margin-top: var(--animated-ease-in-out-margin-top, 0);
margin-bottom: var(--animated-ease-in-out-margin-bottom, 0);
transition:
height var(--animated-ease-in-out-duration) ease-in-out,
opacity var(--animated-ease-in-out-duration) ease-in-out,
margin var(--animated-ease-in-out-duration) ease-in-out;
&[data-duration='instant'] {
--animated-ease-in-out-duration: #{duration(instant)};
}
&[data-duration='fast'] {
--animated-ease-in-out-duration: #{duration(fast)};
}
&[data-duration='slow'] {
--animated-ease-in-out-duration: #{duration(slow)};
}
&[data-starting-style] {
height: 0;
opacity: 0;
}
&[data-ending-style] {
height: 0;
opacity: 0;
margin-top: 0;
margin-bottom: 0;
}
}
@@ -0,0 +1,4 @@
declare const classNames: {
readonly panel: 'panel';
};
export default classNames;
@@ -1,6 +1,7 @@
import { AnimatePresence, motion } from 'framer-motion';
import { Collapsible } from '@base-ui/react/collapsible';
import { type AnimationDuration } from '@ui/theme';
import { useTheme } from '@ui/theme-constants';
import styles from './AnimatedEaseInOut.module.scss';
type AnimatedEaseInOutProps = {
isOpen: boolean;
@@ -17,30 +18,19 @@ export const AnimatedEaseInOut = ({
marginBottom,
marginTop,
duration = 'normal',
initial = true,
}: AnimatedEaseInOutProps) => {
const theme = useTheme();
return (
<AnimatePresence initial={initial}>
{isOpen && (
<motion.div
initial={{
marginBottom: marginBottom ?? 0,
marginTop: marginTop ?? 0,
height: 0,
opacity: 0,
}}
animate={{ height: 'fit-content', opacity: 1 }}
exit={{ height: 0, opacity: 0, marginBottom: 0, marginTop: 0 }}
transition={{
duration: theme.animation.duration[duration],
ease: 'easeInOut',
}}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
};
}: AnimatedEaseInOutProps) => (
<Collapsible.Root open={isOpen}>
<Collapsible.Panel
className={styles.panel}
data-duration={duration}
style={
{
'--animated-ease-in-out-margin-top': marginTop ?? '0',
'--animated-ease-in-out-margin-bottom': marginBottom ?? '0',
} as React.CSSProperties
}
>
{children}
</Collapsible.Panel>
</Collapsible.Root>
);
@@ -0,0 +1,46 @@
.panel {
--animated-expandable-opacity-duration: #{duration(normal)};
--animated-expandable-size-duration: #{duration(normal)};
opacity: 1;
transition: opacity var(--animated-expandable-opacity-duration) ease-in-out;
&[data-dimension='height'] {
height: var(--collapsible-panel-height);
transition:
height var(--animated-expandable-size-duration) ease-in-out,
opacity var(--animated-expandable-opacity-duration) ease-in-out;
}
&[data-dimension='width'] {
width: var(--collapsible-panel-width);
transition:
width var(--animated-expandable-size-duration) ease-in-out,
opacity var(--animated-expandable-opacity-duration) ease-in-out;
}
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
}
&[data-dimension='height'][data-starting-style],
&[data-dimension='height'][data-ending-style] {
height: 0;
}
&[data-dimension='width'][data-starting-style],
&[data-dimension='width'][data-ending-style] {
width: 0;
}
}
.contained {
overflow: hidden;
display: flex;
flex-direction: column;
&[data-dimension='height'] {
width: 100%;
}
}
@@ -0,0 +1,5 @@
declare const classNames: {
readonly panel: 'panel';
readonly contained: 'contained';
};
export default classNames;
@@ -1,16 +1,14 @@
import { Collapsible } from '@base-ui/react/collapsible';
import { clsx } from 'clsx';
import { type AnimationDimension } from '@ui/layout/AnimatedExpandableContainer/types/AnimationDimension';
import { type AnimationDurationObject } from '@ui/layout/AnimatedExpandableContainer/types/AnimationDurationObject';
import { type AnimationDurations } from '@ui/layout/AnimatedExpandableContainer/types/AnimationDurations';
import { type AnimationMode } from '@ui/layout/AnimatedExpandableContainer/types/AnimationMode';
import { type AnimationSize } from '@ui/layout/AnimatedExpandableContainer/types/AnimationSize';
import { getExpandableAnimationConfig } from '@ui/layout/AnimatedExpandableContainer/utils/getExpandableAnimationConfig';
import { useTheme } from '@ui/theme-constants';
import { AnimatePresence, motion } from 'framer-motion';
import { type ReactNode, useRef, useState } from 'react';
import { isDefined } from '@ui/utilities/utils/isDefined';
import styles from './AnimatedExpandableContainer.module.scss';
type AnimatedExpandableContainerProps = {
children: ReactNode;
children: React.ReactNode;
isExpanded: boolean;
dimension?: AnimationDimension;
animationDurations?: AnimationDurations;
@@ -24,66 +22,25 @@ export const AnimatedExpandableContainer = ({
isExpanded,
dimension = 'height',
animationDurations = 'default',
mode = 'scroll-height',
containAnimation = true,
initial = true,
}: AnimatedExpandableContainerProps) => {
const theme = useTheme();
const contentRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState<AnimationSize>(0);
const normalDuration = theme.animation.duration.normal;
const actualDurations: AnimationDurationObject =
const durationStyle =
animationDurations === 'default'
? {
opacity: normalDuration,
size: normalDuration,
}
: animationDurations;
const updateSize = () => {
if (
mode === 'scroll-height' &&
dimension === 'height' &&
isDefined(contentRef.current)
) {
setSize(contentRef.current.scrollHeight);
}
};
const motionAnimationVariants = getExpandableAnimationConfig(
isExpanded,
dimension,
actualDurations.opacity,
actualDurations.size,
mode === 'fit-content' ? 'fit-content' : size,
);
? undefined
: ({
'--animated-expandable-opacity-duration': `${animationDurations.opacity}s`,
'--animated-expandable-size-duration': `${animationDurations.size}s`,
} as React.CSSProperties);
return (
<AnimatePresence initial={initial}>
{isExpanded && (
<motion.div
ref={contentRef}
initial="initial"
animate="animate"
exit="exit"
variants={motionAnimationVariants}
onAnimationStart={updateSize}
style={
containAnimation
? {
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
width: '100%',
}
: undefined
}
>
{children}
</motion.div>
)}
</AnimatePresence>
<Collapsible.Root open={isExpanded}>
<Collapsible.Panel
className={clsx(styles.panel, containAnimation && styles.contained)}
data-dimension={dimension}
style={durationStyle}
>
{children}
</Collapsible.Panel>
</Collapsible.Root>
);
};
@@ -1,12 +0,0 @@
import { type AnimationDimension } from '@ui/layout/AnimatedExpandableContainer/types/AnimationDimension';
import { getTransitionValues } from '@ui/layout/AnimatedExpandableContainer/utils/getTransitionValues';
export const getCommonStyles = (
dimension: AnimationDimension,
opacityDuration: number,
sizeDuration: number,
) => ({
opacity: 0,
[dimension]: 0,
...getTransitionValues(dimension, opacityDuration, sizeDuration),
});
@@ -1,30 +0,0 @@
import { type AnimationDimension } from '@ui/layout/AnimatedExpandableContainer/types/AnimationDimension';
import { type AnimationSize } from '@ui/layout/AnimatedExpandableContainer/types/AnimationSize';
import { getCommonStyles } from '@ui/layout/AnimatedExpandableContainer/utils/getCommonStyles';
import { getTransitionValues } from '@ui/layout/AnimatedExpandableContainer/utils/getTransitionValues';
export const getExpandableAnimationConfig = (
isExpanded: boolean,
dimension: AnimationDimension,
opacityDuration: number,
sizeDuration: number,
size: AnimationSize,
) => ({
initial: {
...getCommonStyles(dimension, opacityDuration, sizeDuration),
},
animate: {
opacity: 1,
[dimension]: isExpanded
? size === 'fit-content'
? 'fit-content'
: dimension === 'width'
? '100%'
: size
: 0,
...getTransitionValues(dimension, opacityDuration, sizeDuration),
},
exit: {
...getCommonStyles(dimension, opacityDuration, sizeDuration),
},
});
@@ -1,18 +0,0 @@
import { type AnimationDimension } from '@ui/layout/AnimatedExpandableContainer/types/AnimationDimension';
export const getTransitionValues = (
dimension: AnimationDimension,
opacityDuration: number,
sizeDuration: number,
) => ({
transition: {
opacity: {
duration: opacityDuration,
ease: 'easeInOut',
},
[dimension]: {
duration: sizeDuration,
ease: 'easeInOut',
},
},
});
@@ -1,42 +0,0 @@
import { AnimatePresence, motion } from 'framer-motion';
import { type AnimationDuration } from '@ui/theme';
import { useTheme } from '@ui/theme-constants';
type AnimatedFadeOutProps = {
isOpen: boolean;
children: React.ReactNode;
duration?: AnimationDuration;
marginBottom?: string;
marginTop?: string;
};
export const AnimatedFadeOut = ({
isOpen,
children,
duration = 'normal',
marginBottom,
marginTop,
}: AnimatedFadeOutProps) => {
const theme = useTheme();
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{
opacity: 1,
marginBottom: marginBottom ?? 0,
marginTop: marginTop ?? 0,
}}
exit={{ opacity: 0, height: 0, marginBottom: 0, marginTop: 0 }}
transition={{
duration: theme.animation.duration[duration],
ease: 'easeOut',
}}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
};
@@ -11,4 +11,17 @@
inset: 0;
justify-content: center;
position: absolute;
transition:
opacity duration(fast) ease-in-out,
transform duration(fast) ease-in-out;
}
.visible {
opacity: 1;
transform: scale(1);
}
.hidden {
opacity: 0;
transform: scale(0.85);
}
@@ -1,5 +1,7 @@
declare const classNames: {
readonly container: 'container';
readonly layer: 'layer';
readonly visible: 'visible';
readonly hidden: 'hidden';
};
export default classNames;
@@ -1,5 +1,4 @@
import { motion } from 'framer-motion';
import React from 'react';
import { clsx } from 'clsx';
import { type IconComponent } from '@ui/icon/types/IconComponent';
import { useTheme } from '@ui/theme-constants';
@@ -32,34 +31,22 @@ export const AnimatedIconCrossfade = ({
} as React.CSSProperties
}
>
<motion.div
className={styles.layer}
initial={false}
animate={{
opacity: isActive ? 0 : 1,
scale: isActive ? 0.85 : 1,
}}
transition={{
duration: theme.animation.duration.fast,
ease: 'easeInOut',
}}
<div
className={clsx(
styles.layer,
isActive ? styles.hidden : styles.visible,
)}
>
<InactiveIcon size={iconSize} />
</motion.div>
<motion.div
className={styles.layer}
initial={false}
animate={{
opacity: isActive ? 1 : 0,
scale: isActive ? 1 : 0.85,
}}
transition={{
duration: theme.animation.duration.fast,
ease: 'easeInOut',
}}
</div>
<div
className={clsx(
styles.layer,
isActive ? styles.visible : styles.hidden,
)}
>
<ActiveIcon size={iconSize} />
</motion.div>
</div>
</div>
);
};
@@ -1,5 +1,40 @@
.container {
--animated-rotate-duration: #{duration(fast)};
align-items: center;
display: flex;
justify-content: center;
animation: animatedRotateIn var(--animated-rotate-duration) ease;
&[data-duration='instant'] {
--animated-rotate-duration: #{duration(instant)};
}
&[data-duration='normal'] {
--animated-rotate-duration: #{duration(normal)};
}
&[data-duration='slow'] {
--animated-rotate-duration: #{duration(slow)};
}
}
.animateOnHover {
transition: transform duration(fast) ease;
&:hover {
transform: rotate(45deg);
}
}
@keyframes animatedRotateIn {
from {
opacity: 0;
transform: rotate(-90deg);
}
to {
opacity: 1;
transform: rotate(0deg);
}
}
@@ -1,4 +1,5 @@
declare const classNames: {
readonly container: 'container';
readonly animateOnHover: 'animateOnHover';
};
export default classNames;
@@ -1,14 +1,12 @@
import { type HTMLMotionProps, motion } from 'framer-motion';
import { type AnimationDuration } from '@ui/theme';
import { useTheme } from '@ui/theme-constants';
import { clsx } from 'clsx';
import { type JSX } from 'react';
import { type AnimationDuration } from '@ui/theme';
import styles from './AnimatedRotate.module.scss';
type AnimatedRotateProps = Omit<
HTMLMotionProps<'div'>,
'initial' | 'animate' | 'transition' | 'exit'
> & {
type AnimatedRotateProps = {
children?: React.ReactNode;
duration?: AnimationDuration;
animateOnHover?: boolean;
};
@@ -17,35 +15,11 @@ export const AnimatedRotate = ({
children,
duration = 'fast',
animateOnHover,
}: AnimatedRotateProps): JSX.Element => {
const theme = useTheme();
const initial = { opacity: 0, rotate: -90 };
const animate = { opacity: 1, rotate: 0 };
const exit = { opacity: 0, rotate: 90 };
const transition = {
duration: theme.animation.duration[duration],
};
return (
<motion.div
className={styles.container}
initial={initial}
animate={animate}
transition={transition}
exit={exit}
whileHover={
animateOnHover
? {
rotate: 45,
transition: {
duration: theme.animation.duration.fast,
},
}
: {}
}
>
{children}
</motion.div>
);
};
}: AnimatedRotateProps): JSX.Element => (
<div
className={clsx(styles.container, animateOnHover && styles.animateOnHover)}
data-duration={duration}
>
{children}
</div>
);
@@ -1,8 +0,0 @@
.container {
display: flex;
overflow: hidden;
}
.word {
white-space: pre;
}
@@ -1,69 +0,0 @@
import { motion } from 'framer-motion';
import React, { useMemo } from 'react';
import styles from './AnimatedTextWord.module.scss';
type AnimatedTextWordProps = Omit<
React.ComponentProps<typeof motion.div>,
'children'
> & {
text: string;
};
const containerAnimation = {
hidden: { opacity: 0 },
visible: (i = 1) => ({
opacity: 1,
transition: { staggerChildren: 0.12, delayChildren: 0.04 * i },
}),
};
const childAnimation = {
visible: {
opacity: 1,
x: 0,
transition: {
type: 'spring',
damping: 12,
stiffness: 100,
},
},
hidden: {
opacity: 0,
x: 20,
transition: {
type: 'spring',
damping: 12,
stiffness: 100,
},
},
};
export const AnimatedTextWord = ({ text = '' }: AnimatedTextWordProps) => {
const words = useMemo(() => {
const words = text.split(' ');
return words.map((value, index) =>
index === words.length - 1 ? value : value + ' ',
);
}, [text]);
return (
<motion.div
className={styles.container}
variants={containerAnimation}
initial="hidden"
animate="visible"
>
{words.map((word, index) => (
<motion.span
className={styles.word}
variants={childAnimation}
key={index}
>
{word}
</motion.span>
))}
</motion.div>
);
};
@@ -1,33 +0,0 @@
import { motion } from 'framer-motion';
import { useTheme } from '@ui/theme-constants';
type AnimatedTranslationProps = {
children: React.ReactNode;
};
export const AnimatedTranslation = ({ children }: AnimatedTranslationProps) => {
const theme = useTheme();
return (
<motion.div
initial="hidden"
animate="visible"
variants={{
hidden: {
opacity: 0,
y: -20,
},
visible: {
opacity: 1,
y: 0,
transition: {
duration: theme.animation.duration.normal,
ease: 'easeInOut',
},
},
}}
>
{children}
</motion.div>
);
};
-6
View File
@@ -17,14 +17,8 @@ export type { AnimationDurationObject } from './AnimatedExpandableContainer/type
export type { AnimationDurations } from './AnimatedExpandableContainer/types/AnimationDurations';
export type { AnimationMode } from './AnimatedExpandableContainer/types/AnimationMode';
export type { AnimationSize } from './AnimatedExpandableContainer/types/AnimationSize';
export { getCommonStyles } from './AnimatedExpandableContainer/utils/getCommonStyles';
export { getExpandableAnimationConfig } from './AnimatedExpandableContainer/utils/getExpandableAnimationConfig';
export { getTransitionValues } from './AnimatedExpandableContainer/utils/getTransitionValues';
export { AnimatedFadeOut } from './AnimatedFadeOut/AnimatedFadeOut';
export { AnimatedIconCrossfade } from './AnimatedIconCrossfade/AnimatedIconCrossfade';
export { AnimatedRotate } from './AnimatedRotate/AnimatedRotate';
export { AnimatedTextWord } from './AnimatedTextWord/AnimatedTextWord';
export { AnimatedTranslation } from './AnimatedTranslation/AnimatedTranslation';
export { AutogrowWrapper } from './AutogrowWrapper/AutogrowWrapper';
export { HorizontalSeparator } from './HorizontalSeparator/HorizontalSeparator';
export { useResizeHandle } from './ResizeHandle/hooks/useResizeHandle';
@@ -1,6 +1,5 @@
import { clsx } from 'clsx';
import { type HTMLMotionProps, motion } from 'framer-motion';
import { type ReactNode } from 'react';
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
import styles from './CardContent.module.scss';
@@ -10,7 +9,7 @@ type CardContentProps = {
divider?: boolean;
isClickable?: boolean;
hasHoverHighlight?: boolean;
} & Omit<HTMLMotionProps<'div'>, 'theme'>;
} & ComponentPropsWithoutRef<'div'>;
export const CardContent = ({
children,
@@ -21,7 +20,7 @@ export const CardContent = ({
...rest
}: CardContentProps) => {
return (
<motion.div
<div
className={clsx(styles.cardContent, className)}
data-divider={divider || undefined}
data-clickable={isClickable || undefined}
@@ -30,6 +29,6 @@ export const CardContent = ({
{...rest}
>
{children}
</motion.div>
</div>
);
};
-2
View File
@@ -53629,9 +53629,7 @@ __metadata:
"@vitejs/plugin-react-swc": "npm:^4.3.1"
"@vitest/browser-playwright": "npm:^4.1.0"
clsx: "npm:^2.1.1"
date-fns: "npm:^4.4.0"
esbuild: "npm:^0.28.1"
framer-motion: "npm:^11.18.0"
glob: "npm:^11.1.0"
jest: "npm:29.7.0"
jest-environment-jsdom: "npm:30.0.0-beta.3"