shadimage
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;
  }
}

More from ericts

Browse all components in this library →