Squeeze Animation

A squeeze animation primitive plus a ready-made SVG card icon.

animationsqueeze
import * as React from "react";

import { cn } from "@/lib/utils";

import "./squeeze-animation.css";

type SqueezeAnimationValue = number | string;

export type SqueezeOptions = {
  /** Horizontal rebound distance. Numeric values are converted to px. Set to 0 to disable. */
  shakeX?: SqueezeAnimationValue;
  /** Vertical rebound distance. Numeric values are converted to px. Set to 0 to disable. */
  shakeY?: SqueezeAnimationValue;
  /** Animation duration. Numeric values are converted to ms. */
  duration?: SqueezeAnimationValue;
};

export type SqueezeProps = React.ComponentPropsWithoutRef<"span"> &
  SqueezeOptions & {
    /** Classes applied to the animated child wrapper. */
    targetClassName?: string;
  };

export function Squeeze({
  className,
  children,
  duration,
  shakeX,
  shakeY,
  style,
  targetClassName,
  ...props
}: SqueezeProps) {
  return (
    <span
      data-slot="squeeze"
      className={cn("squeeze-animation inline-flex", className)}
      style={getSqueezeAnimationStyle({ duration, shakeX, shakeY }, style)}
      {...props}
    >
      <span
        data-slot="squeeze-target"
        className={cn("squeeze-animation-target inline-flex", targetClassName)}
      >
        {children}
      </span>
    </span>
  );
}

export type SqueezeAnimationProps = React.ComponentPropsWithoutRef<"div"> &
  SqueezeOptions & {
    /** Classes applied to the animated SVG group. */
    targetClassName?: string;
  };

export function SqueezeAnimation({
  className,
  duration,
  shakeX,
  shakeY,
  style,
  targetClassName,
  ...props
}: SqueezeAnimationProps) {
  return (
    <div
      data-slot="squeeze-animation"
      className={cn(
        "squeeze-animation flex flex-col items-center text-foreground",
        className,
      )}
      style={getSqueezeAnimationStyle({ duration, shakeX, shakeY }, style)}
      {...props}
    >
      <svg
        viewBox="0 0 100 100"
        aria-hidden="true"
        className="squeeze-animation-svg h-auto w-full max-w-80"
      >
        <g
          data-slot="squeeze-animation-target"
          className={cn(
            "squeeze-animation-target squeeze-animation-icon",
            targetClassName,
          )}
        >
          <rect
            x="26"
            y="30"
            width="48"
            height="40"
            rx="12"
            fill="currentColor"
            fillOpacity="0.1"
            stroke="currentColor"
            strokeWidth="6"
          />
          <path
            d="M 38 46 H 62"
            fill="none"
            stroke="currentColor"
            strokeLinecap="round"
            strokeWidth="6"
          />
          <path
            d="M 38 58 H 54"
            fill="none"
            stroke="currentColor"
            strokeLinecap="round"
            strokeWidth="6"
          />
        </g>
      </svg>
    </div>
  );
}

function getSqueezeAnimationStyle(
  {
    duration,
    shakeX,
    shakeY,
  }: Pick<SqueezeOptions, "duration" | "shakeX" | "shakeY">,
  style: React.CSSProperties | undefined,
) {
  if (duration === undefined && shakeX === undefined && shakeY === undefined) {
    return style;
  }

  return {
    ...style,
    ...(duration !== undefined
      ? { "--squeeze-animation-duration": formatDurationValue(duration) }
      : {}),
    ...(shakeX !== undefined
      ? { "--squeeze-animation-shake-x": formatShakeValue(shakeX) }
      : {}),
    ...(shakeY !== undefined
      ? { "--squeeze-animation-shake-y": formatShakeValue(shakeY) }
      : {}),
  } as React.CSSProperties;
}

function formatShakeValue(value: SqueezeAnimationValue | undefined) {
  return typeof value === "number" ? `${value}px` : value;
}

function formatDurationValue(value: SqueezeAnimationValue | undefined) {
  return typeof value === "number" ? `${value}ms` : value;
}

Installation

pnpm dlx shadcn@latest add @ericts/squeeze-animation