Projected Shadow Animation
A CSS-powered primitive that layers an element with projected and contact shadows, then gathers them on hover or active state.
animationprojected-shadow
import * as React from "react";
import { cn } from "@/lib/utils";
import "./projected-shadow-animation.css";
type ProjectedShadowTimingValue = number | string;
type CssVariableStyle = React.CSSProperties & Record<`--${string}`, string>;
export type ProjectedShadowProps = React.ComponentPropsWithoutRef<"span"> & {
/** Keep the gathered state active without requiring hover. */
active?: boolean;
/** Animation duration for release. Numeric values are converted to ms. */
duration?: ProjectedShadowTimingValue;
/** Animation duration for gather. Numeric values are converted to ms. */
activeDuration?: ProjectedShadowTimingValue;
/** CSS easing used by the transform and opacity transitions. */
ease?: string;
/** Optional decorative content for the shadow layers. Defaults to children. */
shadow?: React.ReactNode;
/** Classes applied to the visible animated child wrapper. */
targetClassName?: string;
/** Classes applied to the far projected shadow layer. */
projectedShadowClassName?: string;
/** Classes applied to the close contact shadow layer. */
contactShadowClassName?: string;
/** Hide the far projected shadow layer. */
showProjectedShadow?: boolean;
/** Hide the close contact shadow layer. */
showContactShadow?: boolean;
};
export function ProjectedShadow({
active,
activeDuration,
children,
className,
contactShadowClassName,
duration,
ease,
projectedShadowClassName,
shadow,
showContactShadow = true,
showProjectedShadow = true,
style,
targetClassName,
...props
}: ProjectedShadowProps) {
const shadowContent = shadow ?? children;
return (
<span
data-slot="projected-shadow"
data-active={active ? "true" : undefined}
className={cn(
"projected-shadow relative inline-flex items-center justify-center",
className,
)}
style={getProjectedShadowStyle({ activeDuration, duration, ease }, style)}
{...props}
>
{showProjectedShadow ? (
<span
aria-hidden="true"
data-slot="projected-shadow-projected"
className={cn(
"projected-shadow-layer projected-shadow-projected pointer-events-none absolute inset-0 z-0 inline-flex text-current blur-[6px]",
projectedShadowClassName,
)}
>
{shadowContent}
</span>
) : null}
{showContactShadow ? (
<span
aria-hidden="true"
data-slot="projected-shadow-contact"
className={cn(
"projected-shadow-layer projected-shadow-contact pointer-events-none absolute inset-0 z-0 inline-flex text-current",
contactShadowClassName,
)}
>
{shadowContent}
</span>
) : null}
<span
data-slot="projected-shadow-target"
className={cn(
"projected-shadow-layer projected-shadow-target relative z-10 inline-flex text-current",
targetClassName,
)}
>
{children}
</span>
</span>
);
}
function getProjectedShadowStyle(
{
activeDuration,
duration,
ease,
}: Pick<ProjectedShadowProps, "activeDuration" | "duration" | "ease">,
style: React.CSSProperties | undefined,
) {
if (activeDuration === undefined && duration === undefined && ease === undefined) {
return style;
}
return {
...style,
...(activeDuration !== undefined
? { "--projected-shadow-active-duration": toCssTime(activeDuration) }
: {}),
...(duration !== undefined
? { "--projected-shadow-duration": toCssTime(duration) }
: {}),
...(ease !== undefined ? { "--projected-shadow-ease": ease } : {}),
} as CssVariableStyle;
}
function toCssTime(value: ProjectedShadowTimingValue) {
return typeof value === "number" ? `${value}ms` : value;
}