Todos os componentes

Siri Orb

Siri Orb é um componente de efeito para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

SmoothUI MIT effects

O que é

Siri Orb é um componente de efeito para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Precisa de

npm i motion

Licença

Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

Siri Orb

index.tsx
"use client";

import { cn } from "@/lib/utils";
import {
  type MotionStyle,
  motion,
  type TargetAndTransition,
  type Transition,
  useReducedMotion,
  useTransform,
} from "motion/react";
import {
  type AIAmplitude,
  type AIState,
  getAIStateMotion,
  useAmplitudeValue,
} from "../ai-core";

const SIZE_THRESHOLD_SMALL = 50;
const SIZE_THRESHOLD_TINY = 30;
const SIZE_THRESHOLD_MEDIUM = 100;
const BLUR_MULTIPLIER_SMALL = 0.008;
const BLUR_MIN_SMALL = 1;
const BLUR_MULTIPLIER_LARGE = 0.015;
const BLUR_MIN_LARGE = 4;
const CONTRAST_MULTIPLIER_SMALL = 0.004;
const CONTRAST_MIN_SMALL = 1.2;
const CONTRAST_MULTIPLIER_LARGE = 0.008;
const CONTRAST_MIN_LARGE = 1.5;
const DOT_SIZE_MULTIPLIER_SMALL = 0.004;
const DOT_SIZE_MIN_SMALL = 0.05;
const DOT_SIZE_MULTIPLIER_LARGE = 0.008;
const DOT_SIZE_MIN_LARGE = 0.1;
const SHADOW_MULTIPLIER_SMALL = 0.004;
const SHADOW_MIN_SMALL = 0.5;
const SHADOW_MULTIPLIER_LARGE = 0.008;
const SHADOW_MIN_LARGE = 2;
const MASK_RADIUS_TINY = "0%";
const MASK_RADIUS_SMALL = "5%";
const MASK_RADIUS_MEDIUM = "15%";
const MASK_RADIUS_LARGE = "25%";
const CONTRAST_TINY = 1.1;
const CONTRAST_MULTIPLIER_FINAL = 1.2;
const CONTRAST_MIN_FINAL = 1.3;

/** Loud audio tightens the gradient, which reads as the orb "focusing". */
const AMPLITUDE_BLUR_FALLOFF = 0.45;
/** Amplitude adds at most 12% of extra size on top of the state scale. */
const AMPLITUDE_SCALE_GAIN = 0.12;
/** Single lateral nudge used to signal `error`, well under 200ms. */
const ERROR_SHAKE_KEYFRAMES = [0, -3, 3, 0];
const ERROR_SHAKE_DURATION = 0.18;
const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const;
const GLOW_BLUR_RATIO = 0.28;
const GLOW_MAX_OPACITY = 0.7;
/** Depth rim, as a fraction of the orb size. */
const RIM_RATIO = 0.06;
const RIM_MIN = 1.5;
/** Sheen drift period at rest, before the state speed divides it. */
const DRIFT_BASE_SECONDS = 12;
/** `idle` breathes: a slow, shallow scale cycle that never draws attention. */
const BREATHE_SCALE = [1, 1.035, 1];
const BREATHE_SECONDS = 5.5;
const SPRING_DEFAULT: Transition = {
  bounce: 0.1,
  duration: 0.25,
  type: "spring",
};

export interface SiriOrbProps {
  /**
   * Live audio level, 0–1. Pass the `MotionValue` from `useAudioAmplitude` so
   * the signal never re-renders React.
   */
  amplitude?: AIAmplitude;
  /** Ambient rotation period in seconds, before the state speed multiplier. */
  animationDuration?: number;
  className?: string;
  colors?: {
    bg?: string;
    c1?: string;
    c2?: string;
    c3?: string;
    /** Fourth mesh stop. More stops means fewer visible repeats per rotation. */
    c4?: string;
  };
  size?: string;
  /** Shared AI state driving speed, scale, saturation and reactivity. */
  state?: AIState;
}

const SiriOrb: React.FC<SiriOrbProps> = ({
  size = "192px",
  className,
  colors,
  animationDuration = 20,
  amplitude,
  state = "idle",
}) => {
  const shouldReduceMotion = useReducedMotion();
  const amplitudeValue = useAmplitudeValue(amplitude);
  const stateMotion = getAIStateMotion(state);

  /**
   * Higher chroma than the original pastels.
   *
   * The first palette sat at chroma 0.12–0.15, which washes out once the mesh is
   * blurred and the dot pattern is overlaid on top. Pushing to ~0.2 and adding a
   * saturate() pass to the mesh is what makes the colour survive all that.
   */
  const defaultColors = {
    bg: "oklch(92% 0.03 300)",
    c1: "oklch(68% 0.21 350)", // Pink
    c2: "oklch(70% 0.18 210)", // Blue
    c3: "oklch(66% 0.2 285)", // Violet
    c4: "oklch(72% 0.19 325)", // Magenta, the fourth stop for variety
  };

  const finalColors = { ...defaultColors, ...colors };
  // The bloom stays in the orb's own palette — see the note in ai-orb-aura.
  const glowColor = finalColors.c2;

  // Extract numeric value from size for calculations
  const sizeValue = Number.parseInt(size.replace("px", ""), 10);

  // Responsive calculations based on size
  const blurAmount =
    sizeValue < SIZE_THRESHOLD_SMALL
      ? Math.max(sizeValue * BLUR_MULTIPLIER_SMALL, BLUR_MIN_SMALL) // Reduced blur for small sizes
      : Math.max(sizeValue * BLUR_MULTIPLIER_LARGE, BLUR_MIN_LARGE);

  const contrastAmount =
    sizeValue < SIZE_THRESHOLD_SMALL
      ? Math.max(sizeValue * CONTRAST_MULTIPLIER_SMALL, CONTRAST_MIN_SMALL) // Reduced contrast for small sizes
      : Math.max(sizeValue * CONTRAST_MULTIPLIER_LARGE, CONTRAST_MIN_LARGE);

  const dotSize =
    sizeValue < SIZE_THRESHOLD_SMALL
      ? Math.max(sizeValue * DOT_SIZE_MULTIPLIER_SMALL, DOT_SIZE_MIN_SMALL) // Smaller dots for small sizes
      : Math.max(sizeValue * DOT_SIZE_MULTIPLIER_LARGE, DOT_SIZE_MIN_LARGE);

  const shadowSpread =
    sizeValue < SIZE_THRESHOLD_SMALL
      ? Math.max(sizeValue * SHADOW_MULTIPLIER_SMALL, SHADOW_MIN_SMALL) // Reduced shadow for small sizes
      : Math.max(sizeValue * SHADOW_MULTIPLIER_LARGE, SHADOW_MIN_LARGE);

  // Adjust mask radius based on size to reduce black center in small sizes
  const getMaskRadius = (value: number) => {
    if (value < SIZE_THRESHOLD_TINY) {
      return MASK_RADIUS_TINY;
    }
    if (value < SIZE_THRESHOLD_SMALL) {
      return MASK_RADIUS_SMALL;
    }
    if (value < SIZE_THRESHOLD_MEDIUM) {
      return MASK_RADIUS_MEDIUM;
    }
    return MASK_RADIUS_LARGE;
  };

  const maskRadius = getMaskRadius(sizeValue);

  // Use more subtle contrast for very small sizes
  const getFinalContrast = (value: number) => {
    if (value < SIZE_THRESHOLD_TINY) {
      return CONTRAST_TINY; // Very subtle contrast for tiny sizes
    }
    if (value < SIZE_THRESHOLD_SMALL) {
      return Math.max(
        contrastAmount * CONTRAST_MULTIPLIER_FINAL,
        CONTRAST_MIN_FINAL
      ); // Reduced contrast for small sizes
    }
    return contrastAmount;
  };

  const finalContrast = getFinalContrast(sizeValue);

  // Reactivity is gated by the state preset: `thinking` barely listens so the
  // orb keeps churning internally instead of throbbing with room noise.
  const reactivity = shouldReduceMotion ? 0 : stateMotion.reactivity;

  const reactiveBlur = useTransform(amplitudeValue, (level) => {
    const focus = 1 - level * reactivity * AMPLITUDE_BLUR_FALLOFF;
    return `${blurAmount * focus}px`;
  });

  const reactiveScale = useTransform(
    amplitudeValue,
    (level) => stateMotion.scale + level * reactivity * AMPLITUDE_SCALE_GAIN
  );

  const loopDuration = shouldReduceMotion
    ? animationDuration
    : animationDuration / stateMotion.speed;

  // Rim thickness scales with the orb, so the lit edge reads the same at 24px
  // and at 240px instead of swallowing the small one.
  const rim = Math.max(sizeValue * RIM_RATIO, RIM_MIN);
  // The sheen drifts slower than the mesh rotates; a busier state speeds it up
  // a touch without matching the rotation, which would look mechanical.
  const driftDuration = (DRIFT_BASE_SECONDS / (1 + stateMotion.speed)) * 2;

  const getRootAnimate = (): TargetAndTransition => {
    if (shouldReduceMotion) {
      return { scale: 1, x: 0 };
    }
    if (state === "error") {
      return { scale: 1, x: ERROR_SHAKE_KEYFRAMES };
    }
    if (stateMotion.motif === "breathe") {
      return { scale: BREATHE_SCALE, x: 0 };
    }
    return { scale: 1, x: 0 };
  };

  const getRootTransition = (): Transition => {
    if (shouldReduceMotion) {
      return { duration: 0 };
    }
    if (state === "error") {
      return { duration: ERROR_SHAKE_DURATION, ease: EASE_IN_OUT };
    }
    if (stateMotion.motif === "breathe") {
      return {
        duration: BREATHE_SECONDS,
        ease: EASE_IN_OUT,
        repeat: Number.POSITIVE_INFINITY,
      };
    }
    return SPRING_DEFAULT;
  };

  return (
    // The gradient disc clips its own overflow, so the bloom and the status
    // motif have to live in a wrapper rather than inside it.
    <motion.div
      animate={getRootAnimate()}
      className={cn("relative", className)}
      style={
        {
          "--orb-size": size,
          height: size,
          width: size,
        } as MotionStyle
      }
      transition={getRootTransition()}
    >
      {/* Bloom tinted by the state's semantic accent. */}
      <motion.div
        animate={{ opacity: stateMotion.glow * GLOW_MAX_OPACITY }}
        className="absolute rounded-full"
        style={{
          background: `radial-gradient(circle at 50% 50%, ${glowColor} 0%, transparent 64%)`,
          filter: `blur(calc(var(--orb-size) * ${GLOW_BLUR_RATIO}))`,
          inset: "-12%",
        }}
        transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}
      />

      <motion.div
        className="siri-orb"
        style={
          {
            "--animation-duration": `${loopDuration}s`,
            "--bg": finalColors.bg,
            "--blur-amount": reactiveBlur,
            "--c1": finalColors.c1,
            "--c2": finalColors.c2,
            "--c3": finalColors.c3,
            "--c4": finalColors.c4,
            "--contrast-amount": finalContrast,
            "--dot-size": `${dotSize}px`,
            "--drift-duration": `${driftDuration}s`,
            "--mask-radius": maskRadius,
            "--rim": `${rim}px`,
            "--shadow-spread": `${shadowSpread}px`,
            // Saturation and hue only affect the gradient disc. Applying them
            // to the whole component would desaturate the semantic accent too,
            // and `error` would lose the very red that identifies it.
            filter: `saturate(${stateMotion.saturation}) hue-rotate(${stateMotion.hueRotate}deg)`,
            height: "100%",
            scale: reactiveScale,
            width: "100%",
          } as MotionStyle
        }
      >
        {/* Specular sheen and depth rim. The mesh alone reads as a flat blurred
            disc; a drifting highlight plus a lit top edge and shaded bottom are
            what make it read as a sphere. */}
        <span aria-hidden="true" className="siri-orb-layer siri-orb-sheen" />
        <span aria-hidden="true" className="siri-orb-layer siri-orb-rim" />
        <style>{`
        @property --angle {
          syntax: "<angle>";
          inherits: false;
          initial-value: 0deg;
        }

        .siri-orb {
          display: grid;
          grid-template-areas: "stack";
          overflow: hidden;
          border-radius: 50%;
          position: relative;
          isolation: isolate;
        }

        .siri-orb::before,
        .siri-orb::after,
        .siri-orb > .siri-orb-layer {
          content: "";
          display: block;
          grid-area: stack;
          width: 100%;
          height: 100%;
          border-radius: 50%;
        }

        /* Glassy highlight that drifts, so the sheen never sits still enough to
           read as a printed-on gradient. */
        .siri-orb-sheen {
          background:
            radial-gradient(circle at 30% 24%, hsl(0 0% 100% / 0.32), transparent 34%),
            radial-gradient(circle at 72% 80%, hsl(0 0% 100% / 0.07), transparent 48%);
          mix-blend-mode: screen;
          animation: siri-drift var(--drift-duration) ease-in-out infinite alternate;
        }

        /* Lit top edge, shaded bottom, thin inner ring. */
        .siri-orb-rim {
          box-shadow:
            inset 0 0 0 1px hsl(0 0% 100% / 0.16),
            inset 0 calc(var(--rim) * 1) calc(var(--rim) * 2) hsl(0 0% 100% / 0.22),
            inset 0 calc(var(--rim) * -1.2) calc(var(--rim) * 2.4) hsl(0 0% 0% / 0.4);
          pointer-events: none;
        }

        @keyframes siri-drift {
          0% { transform: translate(-6%, -4%) scale(1.05); }
          100% { transform: translate(7%, 6%) scale(1.12); }
        }

        .siri-orb::before {
          background:
            conic-gradient(
              from calc(var(--angle) * 2) at 25% 70%,
              var(--c3),
              transparent 20% 80%,
              var(--c3)
            ),
            conic-gradient(
              from calc(var(--angle) * 2) at 45% 75%,
              var(--c2),
              transparent 30% 60%,
              var(--c2)
            ),
            conic-gradient(
              from calc(var(--angle) * -3) at 80% 20%,
              var(--c1),
              transparent 40% 60%,
              var(--c1)
            ),
            conic-gradient(
              from calc(var(--angle) * 1.5) at 60% 35%,
              var(--c4),
              transparent 25% 75%,
              var(--c4)
            ),
            conic-gradient(
              from calc(var(--angle) * 2) at 15% 5%,
              var(--c2),
              transparent 10% 90%,
              var(--c2)
            ),
            conic-gradient(
              from calc(var(--angle) * 1) at 20% 80%,
              var(--c1),
              transparent 10% 90%,
              var(--c1)
            ),
            conic-gradient(
              from calc(var(--angle) * -2) at 85% 10%,
              var(--c3),
              transparent 20% 80%,
              var(--c3)
            );
          box-shadow: inset var(--bg) 0 0 var(--shadow-spread)
            calc(var(--shadow-spread) * 0.2);
          /* The saturate() pass is what keeps the colour alive after the blur
             and the overlaid dot pattern have both eaten into it. */
          filter: blur(var(--blur-amount)) contrast(var(--contrast-amount))
            saturate(1.4);
          animation: rotate var(--animation-duration) linear infinite;
        }

        .siri-orb::after {
          background-image: radial-gradient(
            circle at center,
            var(--bg) var(--dot-size),
            transparent var(--dot-size)
          );
          background-size: calc(var(--dot-size) * 2) calc(var(--dot-size) * 2);
          backdrop-filter: blur(calc(var(--blur-amount) * 2))
            contrast(calc(var(--contrast-amount) * 2));
          mix-blend-mode: overlay;
        }

        /* Apply mask only when radius is greater than 0 */
        .siri-orb[style*="--mask-radius: 0%"]::after {
          mask-image: none;
        }

        .siri-orb:not([style*="--mask-radius: 0%"])::after {
          mask-image: radial-gradient(
            black var(--mask-radius),
            transparent 75%
          );
        }

        @keyframes rotate {
          to {
            --angle: 360deg;
          }
        }

        @media (prefers-reduced-motion: reduce) {
          .siri-orb::before,
          .siri-orb-sheen {
            animation: none;
          }
        }
      `}</style>
      </motion.div>
    </motion.div>
  );
};

export default SiriOrb;

Componentes parecidos