Skip to content

Commit

Permalink
feat(Label): celebration animation
Browse files Browse the repository at this point in the history
  • Loading branch information
talkor committed Mar 21, 2024
1 parent 021932e commit 90286e9
Show file tree
Hide file tree
Showing 3 changed files with 229 additions and 15 deletions.
41 changes: 26 additions & 15 deletions packages/core/src/components/Label/Label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { VibeComponent, VibeComponentProps, withStaticProps } from "../../types"
import useClickableProps from "../../hooks/useClickableProps/useClickableProps";
import useMergeRef from "../../hooks/useMergeRef";
import styles from "./Label.module.scss";
import LabelCelebrationAnimation from "./LabelCelebrationAnimation";

export interface LabelProps extends VibeComponentProps {
/**
Expand All @@ -27,6 +28,7 @@ export interface LabelProps extends VibeComponentProps {
isAnimationDisabled?: boolean;
isLegIncluded?: boolean;
onClick?: (event: React.MouseEvent<HTMLSpanElement, MouseEvent>) => void;
celebration?: boolean;
}

const Label: VibeComponent<LabelProps> & {
Expand All @@ -45,7 +47,8 @@ const Label: VibeComponent<LabelProps> & {
isLegIncluded = false,
id,
"data-testid": dataTestId,
onClick
onClick,
celebration
},
ref
) => {
Expand All @@ -62,12 +65,12 @@ const Label: VibeComponent<LabelProps> & {
getStyle(styles, camelCase("kind" + "-" + kind)),
getStyle(styles, camelCase("color" + "-" + color)),
{
[styles.withAnimation]: !isAnimationDisabled,
[styles.withAnimation]: !isAnimationDisabled && !celebration,
[styles.withLeg]: isLegIncluded
},
labelClassName
),
[kind, color, isAnimationDisabled, isLegIncluded, labelClassName]
[kind, color, isAnimationDisabled, isLegIncluded, labelClassName, celebration]
);

const onClickCallback = useCallback(
Expand All @@ -92,19 +95,27 @@ const Label: VibeComponent<LabelProps> & {
);

return (
<span
{...(isClickable && clickableProps)}
className={cx({ [styles.clickable]: isClickable }, overrideClassName)}
data-testid={dataTestId || getTestId(ComponentDefaultTestId.LABEL, id)}
ref={mergedRef}
>
<Text element="span" type={Text.types.TEXT2} className={classNames} color={Text.colors.ON_INVERTED}>
<Text element="span" type={Text.types.TEXT2} color={Text.colors.INHERIT}>
{text}
<LabelCelebrationAnimation active={celebration && kind === "line"}>
<span
{...(isClickable && clickableProps)}
className={cx({ [styles.clickable]: isClickable }, overrideClassName)}
data-testid={dataTestId || getTestId(ComponentDefaultTestId.LABEL, id)}
ref={mergedRef}
>
<Text
element="span"
type={Text.types.TEXT2}
className={classNames}
color={Text.colors.ON_INVERTED}
data-celebration-text={celebration}
>
<Text element="span" type={Text.types.TEXT2} color={Text.colors.INHERIT}>
{text}
</Text>
<span className={cx(styles.legWrapper)}>{isLegIncluded ? <Leg /> : null}</span>
</Text>
<span className={cx(styles.legWrapper)}>{isLegIncluded ? <Leg /> : null}</span>
</Text>
</span>
</span>
</LabelCelebrationAnimation>
);
}
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
.celebration {
// Fallback perimeter, changes according to the path length
--container-perimeter: 840;
position: relative;

.svg {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;

.stroke {
fill: none;
stroke-width: 1;
stroke-linecap: round;
stroke-linejoin: round;
animation: stroke-rotate cubic-bezier(0.33, 0, 0.67, 1) forwards;
stroke-dasharray: var(--container-perimeter);
stroke-dashoffset: var(--container-perimeter);

&.base {
stroke: #ffcc00;
animation: fade 200ms linear forwards;
animation-delay: 80ms;
stroke-dasharray: 0;
stroke-dashoffset: 0;
opacity: 0;
}

&.first {
stroke: #00ca72;
animation-delay: 80ms;
animation-duration: 320ms;
}

&.second {
stroke: #fb275d;
animation-delay: 200ms;
animation-duration: 320ms;
}

&.third {
stroke: var(--primary-color);
animation-delay: 360ms;
animation-duration: 320ms;
}
}
}

[data-celebration-text] {
background-size: 300% 100%;
background-repeat: no-repeat;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
animation: gradient-text-fill 680ms linear forwards;
background-image: linear-gradient(
to right,
var(--primary-color) 30%,
#fb275d 40%,
#00ca72 60%,
#ffcc00 85%,
transparent 90%
);
}
}

@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

@keyframes stroke-rotate {
to {
stroke-dashoffset: 0;
}
}

@keyframes gradient-text-fill {
from {
background-position: 150% 0;
}
to {
background-position: 0% 0;
}
}
112 changes: 112 additions & 0 deletions packages/core/src/components/Label/LabelCelebrationAnimation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import React, { cloneElement, forwardRef, useCallback, useEffect, useRef, useState } from "react";
import cx from "classnames";
import useResizeObserver from "../../hooks/useResizeObserver";
import styles from "./LabelCelebrationAnimation.module.scss";

const DEFAULT_BORDER_RADIUS = 4;
const DEFAULT_STROKE_WIDTH = 1;

export interface LabelCelebrationAnimationProps {
children: React.ReactElement;
active: boolean;
}

function LabelCelebrationAnimation({ children, active: isActive }: LabelCelebrationAnimationProps) {
const wrapperRef = useRef<HTMLDivElement>();
const childRef = useRef<HTMLDivElement>();

const [active, setActive] = useState<boolean>(isActive);
const [path, setPath] = useState<string>();

useEffect(() => {
setActive(isActive);
}, [isActive]);

const resizeObserverCallback = useCallback(
({ borderBoxSize }: { borderBoxSize: { blockSize: number; inlineSize: number } }) => {
const { blockSize: height, inlineSize: width } = borderBoxSize || {};

if (wrapperRef.current && active) {
const d = getPath({ width, height });
setPath(d);

const perimeter = getPerimeter({ width, height });
wrapperRef.current.style.setProperty("--container-perimeter", String(perimeter));
}
},
[active]
);

useResizeObserver({
ref: wrapperRef,
callback: resizeObserverCallback,
debounceTime: 0
});

const ChildComponentWithRef = forwardRef((_props, ref) =>
cloneElement(children, {
ref
})
);

if (!active) {
return children;
}

return (
<div className={styles.celebration} ref={wrapperRef}>
<svg className={styles.svg}>
<path className={cx(styles.stroke, styles.base)} d={path} />
<path className={cx(styles.stroke, styles.first)} d={path} />
<path className={cx(styles.stroke, styles.second)} d={path} />
<path
className={cx(styles.stroke, styles.third)}
d={path}
onAnimationEnd={() => {
setActive(false);
}}
/>
</svg>
<ChildComponentWithRef ref={childRef} />
</div>
);
}

export default LabelCelebrationAnimation;

function getPath({
width,
height,
borderRadius = DEFAULT_BORDER_RADIUS,
strokeWidth = DEFAULT_STROKE_WIDTH
}: {
width: number;
height: number;
borderRadius?: number;
strokeWidth?: number;
}) {
return `M ${width - strokeWidth / 2}, ${borderRadius} V ${
height - borderRadius
} A ${borderRadius} ${borderRadius} 0 0 1 ${width - borderRadius} ${
height - strokeWidth / 2
} H ${borderRadius} A ${borderRadius} ${borderRadius} 0 0 1 ${strokeWidth / 2} ${
height - borderRadius
} V ${borderRadius} A ${borderRadius} ${borderRadius} 0 0 1 ${borderRadius} ${strokeWidth / 2} L ${
width - borderRadius
}, ${strokeWidth / 2} A ${borderRadius} ${borderRadius} 0 0 1 ${width - strokeWidth / 2} ${borderRadius} Z`;
}

function getPerimeter({
width,
height,
borderRadius = DEFAULT_BORDER_RADIUS
}: {
width: number;
height: number;
borderRadius?: number;
}) {
const straightWidth = width - 2 * borderRadius;
const straightHeight = height - 2 * borderRadius;
const cornerCircumference = 2 * Math.PI * borderRadius;
return cornerCircumference + 2 * straightWidth + 2 * straightHeight;
}

0 comments on commit 90286e9

Please sign in to comment.