ericts
uiprojected-shadowsource: ok · refreshed
Projected Shadow
A CSS-powered primitive that layers an element with projected and contact shadows, supports an animation opt-out, and gathers on hover or active state.
npx shadcn add "https://ui.ericts.com/r/projected-shadow.json"free
Where it goes
statscontact
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files2 · source cached 10/8/2026, 9:28:13 AM
registry/base/ui/projected-shadow.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
import "./projected-shadow.css";
type ProjectedShadowTimingValue = number | string;
type ProjectedShadowLengthValue = number | string;
type CssVariableStyle = React.CSSProperties & Record<`--${string}`, string>;
export type ProjectedShadowOffset = {
/** Horizontal offset. Numeric values are converted to px. */
x?: ProjectedShadowLengthValue;
/** Vertical offset. Numeric values are converted to px. */
y?: ProjectedShadowLengthValue;
};
/**
* Note: `children` is rendered up to three times — once in each decorative
* shadow layer (projected and contact) and once as the visible target. The
* shadow copies are `aria-hidden` and `inert`, so interactive children are not
* duplicated as tab stops, but anything carrying an `id`, or a heavy node like
* an `img`/`video`, is still duplicated in the DOM. Pass a lightweight `shadow`
* to decouple the layers, or set `animated={false}` (or hide both layers) to
* render `children` only once.
*/
export type ProjectedShadowProps = React.ComponentProps<"span"> & {
/** Keep the gathered state active without requiring hover. */
active?: boolean;
/** Enable the layered hover/focus animation. Set false for a neutral target. */
animated?: 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;
/** Blur radius for the far projected shadow. Numeric values are converted to px. */
projectedShadowBlur?: ProjectedShadowLengthValue;
/** Resting offset for the far projected shadow. */
projectedShadowOffset?: ProjectedShadowOffset;
/**
* Keep the wrapped content selectable on touch. Off by default, because the
* press that gathers the layers would otherwise raise iOS's selection menu
* partway through. Turn it on when wrapping copy rather than a mark, and
* accept that a long press interrupts the gather.
*/
selectable?: boolean;
/** 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;
/** Render the far projected shadow layer. Defaults to true. */
showProjectedShadow?: boolean;
/** Render the close contact shadow layer. Defaults to true. */
showContactShadow?: boolean;
};
export function ProjectedShadow({
active,
activeDuration,
animated = true,
children,
className,
contactShadowClassName,
duration,
ease,
projectedShadowClassName,
projectedShadowBlur,
projectedShadowOffset,
selectable,
shadow,
showContactShadow = true,
showProjectedShadow = true,
style,
targetClassName,
...props
}: ProjectedShadowProps) {
const shadowContent = shadow ?? children;
return (
<span
data-slot="projected-shadow"
data-active={animated && active ? "" : undefined}
data-animated={animated ? "" : undefined}
data-selectable={selectable ? "" : undefined}
className={cn(
"projected-shadow relative inline-flex items-center justify-center",
className,
)}
style={getProjectedShadowStyle(
{
activeDuration,
duration,
ease,
projectedShadowBlur,
projectedShadowOffset,
},
style,
)}
{...props}
>
{animated && showProjectedShadow ? (
<span
aria-hidden="true"
inert
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",
projectedShadowClassName,
)}
>
{shadowContent}
</span>
) : null}
{animated && showContactShadow ? (
<span
aria-hidden="true"
inert
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,
projectedShadowBlur,
projectedShadowOffset,
}: Pick<
ProjectedShadowProps,
| "activeDuration"
| "duration"
| "ease"
| "projectedShadowBlur"
| "projectedShadowOffset"
>,
style: React.CSSProperties | undefined,
) {
if (
activeDuration === undefined &&
duration === undefined &&
ease === undefined &&
projectedShadowBlur === undefined &&
projectedShadowOffset?.x === undefined &&
projectedShadowOffset?.y === 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 } : {}),
...(projectedShadowBlur !== undefined
? { "--projected-shadow-blur": toCssLength(projectedShadowBlur) }
: {}),
...(projectedShadowOffset?.x !== undefined
? {
"--projected-shadow-projected-x": toCssLength(
projectedShadowOffset.x,
),
}
: {}),
...(projectedShadowOffset?.y !== undefined
? {
"--projected-shadow-projected-y": toCssLength(
projectedShadowOffset.y,
),
}
: {}),
} as CssVariableStyle;
}
function toCssTime(value: ProjectedShadowTimingValue) {
return typeof value === "number" ? `${value}ms` : value;
}
function toCssLength(value: ProjectedShadowLengthValue) {
return typeof value === "number" ? `${value}px` : value;
}
registry/base/ui/projected-shadow.css
/* Defaults are layered for customization. Normal unlayered styles and
utilities in a later layer can override them. */
@layer components {
.projected-shadow {
--projected-shadow-duration: 280ms;
--projected-shadow-active-duration: 360ms;
--projected-shadow-ease: cubic-bezier(0.165, 0.84, 0.44, 1);
--projected-shadow-origin: center;
--projected-shadow-blur: 6px;
--projected-shadow-projected-opacity: 1;
--projected-shadow-projected-active-opacity: 0;
--projected-shadow-projected-x: -14%;
--projected-shadow-projected-y: -18%;
--projected-shadow-projected-scale: 1.08;
--projected-shadow-projected-rotate: -8deg;
--projected-shadow-projected-active-x: -4%;
--projected-shadow-projected-active-y: -5%;
--projected-shadow-projected-active-scale: 0.94;
--projected-shadow-projected-active-rotate: -3deg;
--projected-shadow-contact-opacity: 0;
--projected-shadow-contact-active-opacity: 0.72;
--projected-shadow-contact-x: -4%;
--projected-shadow-contact-y: -5%;
--projected-shadow-contact-scale: 0.92;
--projected-shadow-contact-rotate: -2deg;
--projected-shadow-contact-active-x: 0%;
--projected-shadow-contact-active-y: 0%;
--projected-shadow-contact-active-scale: 0.89;
--projected-shadow-contact-active-rotate: 0deg;
--projected-shadow-target-x: 8%;
--projected-shadow-target-y: -6%;
--projected-shadow-target-scale: 1.06;
--projected-shadow-target-rotate: 10deg;
--projected-shadow-target-active-x: 0%;
--projected-shadow-target-active-y: 0%;
--projected-shadow-target-active-scale: 0.86;
--projected-shadow-target-active-rotate: 0deg;
}
.projected-shadow-layer {
backface-visibility: hidden;
transform-origin: var(--projected-shadow-origin);
}
.projected-shadow[data-animated] .projected-shadow-layer {
transition-property: opacity, transform;
transition-duration: var(--projected-shadow-duration);
transition-timing-function: var(--projected-shadow-ease);
}
/* Promote only while the gather can actually run. Three layers per instance,
permanently promoted, is real GPU memory in a grid of these. `:active` is
free to list here even though only touch gathers on it: a mouse press
implies hover and a keyboard press implies focus, so on those devices the
layer is already promoted by the time this matches. */
.projected-shadow[data-animated]:hover .projected-shadow-layer,
.projected-shadow[data-animated]:focus-within .projected-shadow-layer,
.projected-shadow[data-animated]:active .projected-shadow-layer,
.projected-shadow[data-animated][data-active] .projected-shadow-layer {
will-change: transform, opacity;
}
.projected-shadow[data-animated] .projected-shadow-projected {
filter: blur(var(--projected-shadow-blur));
opacity: var(--projected-shadow-projected-opacity);
transform: translate3d(
var(--projected-shadow-projected-x),
var(--projected-shadow-projected-y),
0
)
scale(var(--projected-shadow-projected-scale))
rotate(var(--projected-shadow-projected-rotate));
}
.projected-shadow[data-animated] .projected-shadow-contact {
opacity: var(--projected-shadow-contact-opacity);
transform: translate3d(
var(--projected-shadow-contact-x),
var(--projected-shadow-contact-y),
0
)
scale(var(--projected-shadow-contact-scale))
rotate(var(--projected-shadow-contact-rotate));
}
.projected-shadow[data-animated] .projected-shadow-target {
transform: translate3d(
var(--projected-shadow-target-x),
var(--projected-shadow-target-y),
0
)
scale(var(--projected-shadow-target-scale))
rotate(var(--projected-shadow-target-rotate));
}
.projected-shadow[data-animated][data-active] .projected-shadow-layer {
transition-duration: var(--projected-shadow-active-duration);
}
.projected-shadow[data-animated][data-active] .projected-shadow-projected {
opacity: var(--projected-shadow-projected-active-opacity);
transform: translate3d(
var(--projected-shadow-projected-active-x),
var(--projected-shadow-projected-active-y),
0
)
scale(var(--projected-shadow-projected-active-scale))
rotate(var(--projected-shadow-projected-active-rotate));
}
.projected-shadow[data-animated][data-active] .projected-shadow-contact {
opacity: var(--projected-shadow-contact-active-opacity);
transform: translate3d(
var(--projected-shadow-contact-active-x),
var(--projected-shadow-contact-active-y),
0
)
scale(var(--projected-shadow-contact-active-scale))
rotate(var(--projected-shadow-contact-active-rotate));
}
.projected-shadow[data-animated][data-active] .projected-shadow-target {
transform: translate3d(
var(--projected-shadow-target-active-x),
var(--projected-shadow-target-active-y),
0
)
scale(var(--projected-shadow-target-active-scale))
rotate(var(--projected-shadow-target-active-rotate));
}
@media (hover: hover) and (pointer: fine) {
.projected-shadow[data-animated]:hover .projected-shadow-layer {
transition-duration: var(--projected-shadow-active-duration);
}
.projected-shadow[data-animated]:hover .projected-shadow-projected {
opacity: var(--projected-shadow-projected-active-opacity);
transform: translate3d(
var(--projected-shadow-projected-active-x),
var(--projected-shadow-projected-active-y),
0
)
scale(var(--projected-shadow-projected-active-scale))
rotate(var(--projected-shadow-projected-active-rotate));
}
.projected-shadow[data-animated]:hover .projected-shadow-contact {
opacity: var(--projected-shadow-contact-active-opacity);
transform: translate3d(
var(--projected-shadow-contact-active-x),
var(--projected-shadow-contact-active-y),
0
)
scale(var(--projected-shadow-contact-active-scale))
rotate(var(--projected-shadow-contact-active-rotate));
}
.projected-shadow[data-animated]:hover .projected-shadow-target {
transform: translate3d(
var(--projected-shadow-target-active-x),
var(--projected-shadow-target-active-y),
0
)
scale(var(--projected-shadow-target-active-scale))
rotate(var(--projected-shadow-target-active-rotate));
}
}
/* Touch parity: a press stands in for the hover the device cannot deliver,
so the gather is reachable without the consumer wiring up `active`.
`cursor: pointer` is the signal Safari uses to decide an element is
clickable and therefore eligible for `:active`; where there is no hover
there is no cursor, so declaring it costs nothing visually. */
@media (hover: none) {
.projected-shadow[data-animated] {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
/* The callout and selection suppressions keep a press-and-hold from turning
into iOS's text selection menu halfway through the gather. They inherit
into whatever is wrapped, so a consumer wrapping real copy rather than a
mark opts back out with `selectable` and accepts the interruption. */
.projected-shadow[data-animated]:not([data-selectable]) {
-webkit-touch-callout: none;
user-select: none;
}
.projected-shadow[data-animated]:active .projected-shadow-layer {
transition-duration: var(--projected-shadow-active-duration);
}
.projected-shadow[data-animated]:active .projected-shadow-projected {
opacity: var(--projected-shadow-projected-active-opacity);
transform: translate3d(
var(--projected-shadow-projected-active-x),
var(--projected-shadow-projected-active-y),
0
)
scale(var(--projected-shadow-projected-active-scale))
rotate(var(--projected-shadow-projected-active-rotate));
}
.projected-shadow[data-animated]:active .projected-shadow-contact {
opacity: var(--projected-shadow-contact-active-opacity);
transform: translate3d(
var(--projected-shadow-contact-active-x),
var(--projected-shadow-contact-active-y),
0
)
scale(var(--projected-shadow-contact-active-scale))
rotate(var(--projected-shadow-contact-active-rotate));
}
.projected-shadow[data-animated]:active .projected-shadow-target {
transform: translate3d(
var(--projected-shadow-target-active-x),
var(--projected-shadow-target-active-y),
0
)
scale(var(--projected-shadow-target-active-scale))
rotate(var(--projected-shadow-target-active-rotate));
}
}
/* Keyboard parity with the hover gather when the wrapped child is focusable. */
.projected-shadow[data-animated]:focus-within .projected-shadow-layer {
transition-duration: var(--projected-shadow-active-duration);
}
.projected-shadow[data-animated]:focus-within .projected-shadow-projected {
opacity: var(--projected-shadow-projected-active-opacity);
transform: translate3d(
var(--projected-shadow-projected-active-x),
var(--projected-shadow-projected-active-y),
0
)
scale(var(--projected-shadow-projected-active-scale))
rotate(var(--projected-shadow-projected-active-rotate));
}
.projected-shadow[data-animated]:focus-within .projected-shadow-contact {
opacity: var(--projected-shadow-contact-active-opacity);
transform: translate3d(
var(--projected-shadow-contact-active-x),
var(--projected-shadow-contact-active-y),
0
)
scale(var(--projected-shadow-contact-active-scale))
rotate(var(--projected-shadow-contact-active-rotate));
}
.projected-shadow[data-animated]:focus-within .projected-shadow-target {
transform: translate3d(
var(--projected-shadow-target-active-x),
var(--projected-shadow-target-active-y),
0
)
scale(var(--projected-shadow-target-active-scale))
rotate(var(--projected-shadow-target-active-rotate));
}
}
/* Motion guards override ordinary customization, including inline timing.
Keep these outside the defaults layer; only motion declarations are important. */
@media (prefers-reduced-motion: reduce) {
.projected-shadow[data-animated] {
--projected-shadow-blur: 0px;
--projected-shadow-projected-opacity: 0;
--projected-shadow-projected-active-opacity: 0;
--projected-shadow-projected-x: 0%;
--projected-shadow-projected-y: 0%;
--projected-shadow-projected-scale: 1;
--projected-shadow-projected-rotate: 0deg;
--projected-shadow-projected-active-x: 0%;
--projected-shadow-projected-active-y: 0%;
--projected-shadow-projected-active-scale: 1;
--projected-shadow-projected-active-rotate: 0deg;
--projected-shadow-contact-opacity: 0;
--projected-shadow-contact-active-opacity: 0;
--projected-shadow-contact-x: 0%;
--projected-shadow-contact-y: 0%;
--projected-shadow-contact-scale: 1;
--projected-shadow-contact-rotate: 0deg;
--projected-shadow-contact-active-x: 0%;
--projected-shadow-contact-active-y: 0%;
--projected-shadow-contact-active-scale: 1;
--projected-shadow-contact-active-rotate: 0deg;
--projected-shadow-target-x: 0%;
--projected-shadow-target-y: 0%;
--projected-shadow-target-scale: 1;
--projected-shadow-target-rotate: 0deg;
--projected-shadow-target-active-x: 0%;
--projected-shadow-target-active-y: 0%;
--projected-shadow-target-active-scale: 1;
--projected-shadow-target-active-rotate: 0deg;
}
.projected-shadow[data-animated] .projected-shadow-layer {
transition-duration: 0ms !important;
transition-delay: 0ms !important;
will-change: auto;
}
}