Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds
Powrót do kolekcji
Przewodnik29 min czytania

Aceternity UI, komponenty React z efektami 3D

Aceternity UI to darmowe komponenty React z efektami 3D i animacjami Motion. Instalacja z Tailwind 4, wydajność, licencja i porównanie z Magic UI.

Aceternity UI, komponenty React z efektami 3D

Aceternity UI to zbiór komponentów React, które kopiujesz bezpośrednio do swojego projektu zamiast instalować jako zależność. Specjalizacja jest wąska i wyraźna: efekty wizualne, czyli karty reagujące na ruch myszy w trzech wymiarach, animowane tła, reflektory podążające za kursorem i przewijające się w nieskończoność karuzele opinii. Podstawą technologiczną są Tailwind CSS i Motion, biblioteka animacji znana wcześniej jako Framer Motion.

Czym Aceternity UI różni się od zwykłej biblioteki komponentów

Rozróżnienie, od którego trzeba zacząć, dotyczy modelu dystrybucji. Klasyczna biblioteka to pakiet w node_modules, który aktualizujesz poleceniem i którego kodu zwykle nie czytasz. Aceternity UI działa odwrotnie: kopiujesz plik komponentu do swojego repozytorium i od tej chwili jest to Twój kod, z Twoimi commitami i Twoją odpowiedzialnością za utrzymanie.

Ten model ma konsekwencję, którą warto rozumieć przed pierwszym użyciem. Aktualizacje nie przychodzą same. Jeśli autor poprawi błąd w komponencie karty 3D pół roku po tym, jak go skopiowałeś, Twoja kopia zostanie z błędem, dopóki sam nie zajrzysz do repozytorium. W zamian dostajesz pełną swobodę: możesz przepisać połowę komponentu i nic się nie zepsuje przy kolejnej instalacji zależności.

Drugie rozróżnienie dotyczy przeznaczenia. To nie jest biblioteka do budowania aplikacji. Nie znajdziesz tu dostępnego pola wyboru, poprawnie obsługiwanego okna modalnego ani tabeli z sortowaniem. Zestaw komponentów jest dobrany pod strony marketingowe: sekcje otwierające, prezentacje funkcji, opinie klientów, wezwania do działania. Jeśli budujesz panel administracyjny, potrzebujesz shadcn/ui albo Radix UI, a Aceternity co najwyżej dołożysz na stronie sprzedażowej tego panelu.

Trzecia rzecz to skala oczekiwań wobec animacji. Efekty w tej bibliotece są celowo wyraziste. Aurora zajmująca cały ekran, reflektor rozjaśniający pół sekcji, meteory przelatujące przez kartę. To działa na stronie produktu, który chce wyglądać na nowoczesny, i wygląda źle na stronie kancelarii prawnej. Dobór biblioteki jest tu decyzją estetyczną wcześniej niż techniczną.

Aceternity UI a alternatywy

CechaAceternity UIMagic UIshadcn/uiReact Bits
Główny obszarefekty 3D i tłamikroanimacje i sekcjekomponenty aplikacjiefekty tekstowe i tła
Model dystrybucjikopiowanie kodukopiowanie kodukopiowanie kodukopiowanie kodu
Zależność od Motionwymaganawymaganaopcjonalnaczęściowa
Dostępność i obsługa klawiaturypoza zakresempoza zakresempriorytetpoza zakresem
Warstwa płatnatak, szablony Protak, szablony Pronienie
Typowe zastosowaniestrona sprzedażowastrona sprzedażowainterfejs produktustrona sprzedażowa

Praktyczna uwaga do tej tabeli: te biblioteki nie wykluczają się nawzajem i w realnym projekcie zwykle współistnieją. Skoro każda z nich to kopiowany kod na Tailwindzie, nic nie stoi na przeszkodzie, żeby sekcja otwierająca pochodziła z Aceternity, formularz kontaktowy z shadcn, a licznik statystyk z Magic UI. Jedyne, o co trzeba zadbać, to spójność funkcji cn i zmiennych motywu, bo każda z bibliotek zakłada nieco inną konfigurację.

Instalacja i konfiguracja

Wymagane zależności

Dokumentacja biblioteki celuje dziś w Next.js 15 i Tailwind CSS w wersji 4, a za animacje odpowiada pakiet motion. To ta sama biblioteka, którą przez lata znaliśmy jako framer-motion, po zmianie nazwy w 2024 roku. Obie nazwy nadal publikują się w rejestrze npm w tej samej wersji, w chwili pisania 13.1.0, więc starsze projekty z importem framer-motion działają bez zmian i nie ma pilnego powodu, żeby je przepisywać.

Code
Bash
npm install motion clsx tailwind-merge

npm install @tabler/icons-react
npm install simplex-noise

Pakiet simplex-noise przydaje się wyłącznie w komponentach generujących szum proceduralny, na przykład w falujących tłach. Nie instaluj go na zapas, bo dokłada kilkadziesiąt kilobajtów do paczki, z których nie skorzystasz.

Warto od razu ustalić jedną rzecz, która oszczędza sporo zamieszania przy pierwszym komponencie. Aceternity nie publikuje pakietu npm z komponentami. Kod dodajesz przez kopiowanie ze strony albo poleceniem npx shadcn@latest add @aceternity/nazwa-komponentu, które wkleja plik do wskazanego katalogu. W obu przypadkach efektem jest plik w Twoim repozytorium, nie wpis w package.json.

Konfiguracja Tailwind CSS

Poniższa konfiguracja opisuje Tailwind w wersji 3, czyli układ z plikiem tailwind.config.js. Jeśli pracujesz na wersji 4, ta sama treść przenosi się do arkusza CSS: klatki kluczowe deklarujesz zwykłym @keyframes, a animacje rejestrujesz przez @theme zamiast rozszerzać obiekt konfiguracji. Zostawiam wariant z plikiem konfiguracyjnym, bo większość istniejących projektów wciąż na nim stoi, a przeniesienie tych deklaracji do CSS jest zadaniem na dziesięć minut.

JStailwind.config.js
JavaScript
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: 'class',
  content: [
    './pages/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
    './app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      animation: {
        spotlight: 'spotlight 2s ease .75s 1 forwards',
        shimmer: 'shimmer 2s linear infinite',
        'meteor-effect': 'meteor 5s linear infinite',
        aurora: 'aurora 60s linear infinite',
        'border-beam': 'border-beam calc(var(--duration)*1s) infinite linear',
      },
      keyframes: {
        spotlight: {
          '0%': {
            opacity: 0,
            transform: 'translate(-72%, -62%) scale(0.5)',
          },
          '100%': {
            opacity: 1,
            transform: 'translate(-50%,-40%) scale(1)',
          },
        },
        shimmer: {
          from: {
            backgroundPosition: '0 0',
          },
          to: {
            backgroundPosition: '-200% 0',
          },
        },
        meteor: {
          '0%': { transform: 'rotate(215deg) translateX(0)', opacity: 1 },
          '70%': { opacity: 1 },
          '100%': {
            transform: 'rotate(215deg) translateX(-500px)',
            opacity: 0,
          },
        },
        aurora: {
          from: {
            backgroundPosition: '50% 50%, 50% 50%',
          },
          to: {
            backgroundPosition: '350% 50%, 350% 50%',
          },
        },
        'border-beam': {
          '100%': {
            'offset-distance': '100%',
          },
        },
      },
    },
  },
  plugins: [],
}

Funkcja pomocnicza cn()

TSlib/utils.ts
TypeScript
// lib/utils.ts
import { ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Popularne komponenty Aceternity UI

3D Card Effect

Karta z efektem 3D follow-mouse - najbardziej ikoniczny komponent biblioteki.

TScomponents/ui/3d-card.tsx
TypeScript
// components/ui/3d-card.tsx
'use client'

import { cn } from '@/lib/utils'
import React, {
  createContext,
  useState,
  useContext,
  useRef,
  useEffect,
} from 'react'

const MouseEnterContext = createContext<
  [boolean, React.Dispatch<React.SetStateAction<boolean>>] | undefined
>(undefined)

export const CardContainer = ({
  children,
  className,
  containerClassName,
}: {
  children?: React.ReactNode
  className?: string
  containerClassName?: string
}) => {
  const containerRef = useRef<HTMLDivElement>(null)
  const [isMouseEntered, setIsMouseEntered] = useState(false)

  const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
    if (!containerRef.current) return
    const { left, top, width, height } =
      containerRef.current.getBoundingClientRect()
    const x = (e.clientX - left - width / 2) / 25
    const y = (e.clientY - top - height / 2) / 25
    containerRef.current.style.transform = `rotateY(${x}deg) rotateX(${y}deg)`
  }

  const handleMouseEnter = () => {
    setIsMouseEntered(true)
  }

  const handleMouseLeave = () => {
    if (!containerRef.current) return
    setIsMouseEntered(false)
    containerRef.current.style.transform = `rotateY(0deg) rotateX(0deg)`
  }

  return (
    <MouseEnterContext.Provider value={[isMouseEntered, setIsMouseEntered]}>
      <div
        className={cn(
          'py-20 flex items-center justify-center',
          containerClassName
        )}
        style={{
          perspective: '1000px',
        }}
      >
        <div
          ref={containerRef}
          onMouseEnter={handleMouseEnter}
          onMouseMove={handleMouseMove}
          onMouseLeave={handleMouseLeave}
          className={cn(
            'flex items-center justify-center relative transition-all duration-200 ease-linear',
            className
          )}
          style={{
            transformStyle: 'preserve-3d',
          }}
        >
          {children}
        </div>
      </div>
    </MouseEnterContext.Provider>
  )
}

export const CardBody = ({
  children,
  className,
}: {
  children: React.ReactNode
  className?: string
}) => {
  return (
    <div
      className={cn(
        'h-96 w-96 [transform-style:preserve-3d] [&>*]:[transform-style:preserve-3d]',
        className
      )}
    >
      {children}
    </div>
  )
}

export const CardItem = ({
  as: Tag = 'div',
  children,
  className,
  translateX = 0,
  translateY = 0,
  translateZ = 0,
  rotateX = 0,
  rotateY = 0,
  rotateZ = 0,
  ...rest
}: {
  as?: React.ElementType
  children: React.ReactNode
  className?: string
  translateX?: number | string
  translateY?: number | string
  translateZ?: number | string
  rotateX?: number | string
  rotateY?: number | string
  rotateZ?: number | string
  [key: string]: unknown
}) => {
  const ref = useRef<HTMLDivElement>(null)
  const [isMouseEntered] = useMouseEnter()

  useEffect(() => {
    handleAnimations()
  }, [isMouseEntered])

  const handleAnimations = () => {
    if (!ref.current) return
    if (isMouseEntered) {
      ref.current.style.transform = `translateX(${translateX}px) translateY(${translateY}px) translateZ(${translateZ}px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) rotateZ(${rotateZ}deg)`
    } else {
      ref.current.style.transform = `translateX(0px) translateY(0px) translateZ(0px) rotateX(0deg) rotateY(0deg) rotateZ(0deg)`
    }
  }

  return (
    <Tag
      ref={ref}
      className={cn('w-fit transition duration-200 ease-linear', className)}
      {...rest}
    >
      {children}
    </Tag>
  )
}

export const useMouseEnter = () => {
  const context = useContext(MouseEnterContext)
  if (context === undefined) {
    throw new Error('useMouseEnter must be used within a MouseEnterProvider')
  }
  return context
}

Przykład użycia 3D Card:

Code
TypeScript
import { CardContainer, CardBody, CardItem } from '@/components/ui/3d-card'
import Image from 'next/image'

export function ThreeDCardDemo() {
  return (
    <CardContainer className="inter-var">
      <CardBody className="bg-gray-50 relative group/card dark:hover:shadow-2xl dark:hover:shadow-emerald-500/[0.1] dark:bg-black dark:border-white/[0.2] border-black/[0.1] w-auto sm:w-[30rem] h-auto rounded-xl p-6 border">
        <CardItem
          translateZ="50"
          className="text-xl font-bold text-neutral-600 dark:text-white"
        >
          Make things float in air
        </CardItem>
        <CardItem
          as="p"
          translateZ="60"
          className="text-neutral-500 text-sm max-w-sm mt-2 dark:text-neutral-300"
        >
          Hover over this card to unleash the power of CSS perspective
        </CardItem>
        <CardItem translateZ="100" className="w-full mt-4">
          <Image
            src="/images/product.png"
            height="1000"
            width="1000"
            className="h-60 w-full object-cover rounded-xl group-hover/card:shadow-xl"
            alt="thumbnail"
          />
        </CardItem>
        <div className="flex justify-between items-center mt-20">
          <CardItem
            translateZ={20}
            as="button"
            className="px-4 py-2 rounded-xl text-xs font-normal dark:text-white"
          >
            Try now →
          </CardItem>
          <CardItem
            translateZ={20}
            as="button"
            className="px-4 py-2 rounded-xl bg-black dark:bg-white dark:text-black text-white text-xs font-bold"
          >
            Sign up
          </CardItem>
        </div>
      </CardBody>
    </CardContainer>
  )
}

Spotlight Effect

Efekt spotlight podążający za kursorem - świetny do hero sections.

TScomponents/ui/spotlight.tsx
TypeScript
// components/ui/spotlight.tsx
'use client'

import React from 'react'
import { cn } from '@/lib/utils'

type SpotlightProps = {
  className?: string
  fill?: string
}

export const Spotlight = ({ className, fill }: SpotlightProps) => {
  return (
    <svg
      className={cn(
        'animate-spotlight pointer-events-none absolute z-[1] h-[169%] w-[138%] lg:w-[84%] opacity-0',
        className
      )}
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 3787 2842"
      fill="none"
    >
      <g filter="url(#filter)">
        <ellipse
          cx="1924.71"
          cy="273.501"
          rx="1924.71"
          ry="273.501"
          transform="matrix(-0.822377 -0.568943 -0.568943 0.822377 3631.88 2291.09)"
          fill={fill || 'white'}
          fillOpacity="0.21"
        ></ellipse>
      </g>
      <defs>
        <filter
          id="filter"
          x="0.860352"
          y="0.838989"
          width="3785.16"
          height="2840.26"
          filterUnits="userSpaceOnUse"
          colorInterpolationFilters="sRGB"
        >
          <feFlood floodOpacity="0" result="BackgroundImageFix"></feFlood>
          <feBlend
            mode="normal"
            in="SourceGraphic"
            in2="BackgroundImageFix"
            result="shape"
          ></feBlend>
          <feGaussianBlur
            stdDeviation="151"
            result="effect1_foregroundBlur_1065_8"
          ></feGaussianBlur>
        </filter>
      </defs>
    </svg>
  )
}

Przykład użycia Spotlight:

Code
TypeScript
import { Spotlight } from '@/components/ui/spotlight'

export function SpotlightPreview() {
  return (
    <div className="h-[40rem] w-full rounded-md flex md:items-center md:justify-center bg-black/[0.96] antialiased bg-grid-white/[0.02] relative overflow-hidden">
      <Spotlight
        className="-top-40 left-0 md:left-60 md:-top-20"
        fill="white"
      />
      <div className="p-4 max-w-7xl mx-auto relative z-10 w-full pt-20 md:pt-0">
        <h1 className="text-4xl md:text-7xl font-bold text-center bg-clip-text text-transparent bg-gradient-to-b from-neutral-50 to-neutral-400 bg-opacity-50">
          Spotlight <br /> is the new trend.
        </h1>
        <p className="mt-4 font-normal text-base text-neutral-300 max-w-lg text-center mx-auto">
          Spotlight effect is a great way to draw attention to a specific part
          of the page. Here, we are drawing the attention towards the text
          section of the page. I don&apos;t know what else to write so I&apos;ll
          just keep writing gibberish.
        </p>
      </div>
    </div>
  )
}

Meteors Effect

Spadające meteory w tle - efekt wizualny inspirowany kosmosem.

TScomponents/ui/meteors.tsx
TypeScript
// components/ui/meteors.tsx
'use client'

import { cn } from '@/lib/utils'
import React from 'react'

export const Meteors = ({
  number,
  className,
}: {
  number?: number
  className?: string
}) => {
  const meteors = new Array(number || 20).fill(true)
  return (
    <>
      {meteors.map((_, idx) => (
        <span
          key={'meteor' + idx}
          className={cn(
            'animate-meteor-effect absolute top-1/2 left-1/2 h-0.5 w-0.5 rounded-[9999px] bg-slate-500 shadow-[0_0_0_1px_#ffffff10] rotate-[215deg]',
            "before:content-[''] before:absolute before:top-1/2 before:-translate-y-[50%] before:w-[50px] before:h-[1px] before:bg-gradient-to-r before:from-[#64748b] before:to-transparent",
            className
          )}
          style={{
            top: 0,
            left: Math.floor(Math.random() * (400 - -400) + -400) + 'px',
            animationDelay: Math.random() * (0.8 - 0.2) + 0.2 + 's',
            animationDuration: Math.floor(Math.random() * (10 - 2) + 2) + 's',
          }}
        ></span>
      ))}
    </>
  )
}

Przykład użycia Meteors:

Code
TypeScript
import { Meteors } from '@/components/ui/meteors'

export function MeteorsDemo() {
  return (
    <div className="w-full relative max-w-xs">
      <div className="absolute inset-0 h-full w-full bg-gradient-to-r from-blue-500 to-teal-500 transform scale-[0.80] bg-red-500 rounded-full blur-3xl" />
      <div className="relative shadow-xl bg-gray-900 border border-gray-800 px-4 py-8 h-full overflow-hidden rounded-2xl flex flex-col justify-end items-start">
        <div className="h-5 w-5 rounded-full border flex items-center justify-center mb-4 border-gray-500">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            fill="none"
            viewBox="0 0 24 24"
            strokeWidth="1.5"
            stroke="currentColor"
            className="h-2 w-2 text-gray-300"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M4.5 4.5l15 15m0 0V8.25m0 11.25H8.25"
            />
          </svg>
        </div>

        <h1 className="font-bold text-xl text-white mb-4 relative z-50">
          Meteors because they&apos;re cool
        </h1>

        <p className="font-normal text-base text-slate-500 mb-4 relative z-50">
          I don&apos;t know what to write so I&apos;ll just paste something cool
          here. One more sentence because lorem ipsum is boring.
        </p>

        <button className="border px-4 py-1 rounded-lg border-gray-500 text-gray-300">
          Explore
        </button>

        <Meteors number={20} />
      </div>
    </div>
  )
}

Aurora Background

Animowane aurora borealis w tle - efekt zorzy polarnej.

TScomponents/ui/aurora-background.tsx
TypeScript
// components/ui/aurora-background.tsx
'use client'

import { cn } from '@/lib/utils'
import React, { ReactNode } from 'react'

interface AuroraBackgroundProps extends React.HTMLProps<HTMLDivElement> {
  children: ReactNode
  showRadialGradient?: boolean
}

export const AuroraBackground = ({
  className,
  children,
  showRadialGradient = true,
  ...props
}: AuroraBackgroundProps) => {
  return (
    <main>
      <div
        className={cn(
          'relative flex flex-col h-[100vh] items-center justify-center bg-zinc-50 dark:bg-zinc-900 text-slate-950 transition-bg',
          className
        )}
        {...props}
      >
        <div className="absolute inset-0 overflow-hidden">
          <div
            className={cn(
              `
            [--white-gradient:repeating-linear-gradient(100deg,var(--white)_0%,var(--white)_7%,var(--transparent)_10%,var(--transparent)_12%,var(--white)_16%)]
            [--dark-gradient:repeating-linear-gradient(100deg,var(--black)_0%,var(--black)_7%,var(--transparent)_10%,var(--transparent)_12%,var(--black)_16%)]
            [--aurora:repeating-linear-gradient(100deg,var(--blue-500)_10%,var(--indigo-300)_15%,var(--blue-300)_20%,var(--violet-200)_25%,var(--blue-400)_30%)]
            [background-image:var(--white-gradient),var(--aurora)]
            dark:[background-image:var(--dark-gradient),var(--aurora)]
            [background-size:300%,_200%]
            [background-position:50%_50%,50%_50%]
            filter blur-[10px] invert dark:invert-0
            after:content-[""] after:absolute after:inset-0 after:[background-image:var(--white-gradient),var(--aurora)]
            after:dark:[background-image:var(--dark-gradient),var(--aurora)]
            after:[background-size:200%,_100%]
            after:animate-aurora after:[background-attachment:fixed] after:mix-blend-difference
            pointer-events-none
            absolute -inset-[10px] opacity-50 will-change-transform`,
              showRadialGradient &&
                `[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,var(--transparent)_70%)]`
            )}
          ></div>
        </div>
        {children}
      </div>
    </main>
  )
}

Przykład użycia Aurora Background:

Code
TypeScript
'use client'

import { motion } from 'framer-motion'
import React from 'react'
import { AuroraBackground } from '@/components/ui/aurora-background'

export function AuroraBackgroundDemo() {
  return (
    <AuroraBackground>
      <motion.div
        initial={{ opacity: 0.0, y: 40 }}
        whileInView={{ opacity: 1, y: 0 }}
        transition={{
          delay: 0.3,
          duration: 0.8,
          ease: 'easeInOut',
        }}
        className="relative flex flex-col gap-4 items-center justify-center px-4"
      >
        <div className="text-3xl md:text-7xl font-bold dark:text-white text-center">
          Background lights are cool you know.
        </div>
        <div className="font-extralight text-base md:text-4xl dark:text-neutral-200 py-4">
          And this, is chemical burn.
        </div>
        <button className="bg-black dark:bg-white rounded-full w-fit text-white dark:text-black px-4 py-2">
          Debug now
        </button>
      </motion.div>
    </AuroraBackground>
  )
}

Sparkles Effect

Animowane iskierki wokół elementu - subtelny efekt magii.

TScomponents/ui/sparkles.tsx
TypeScript
// components/ui/sparkles.tsx
'use client'

import React, { useId, useMemo } from 'react'
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils'

interface SparkleProps {
  id: string
  createdAt: number
  color: string
  size: number
  style: {
    top: string
    left: string
    zIndex: number
  }
}

interface SparklesProps {
  className?: string
  children: React.ReactNode
  sparklesCount?: number
  colors?: {
    first: string
    second: string
  }
}

const DEFAULT_SPARKLE_COLORS = {
  first: '#9E7AFF',
  second: '#FE8BBB',
}

const generateSparkle = (colors = DEFAULT_SPARKLE_COLORS): SparkleProps => {
  return {
    id: String(Math.random()),
    createdAt: Date.now(),
    color: Math.random() > 0.5 ? colors.first : colors.second,
    size: Math.random() * 10 + 10,
    style: {
      top: Math.random() * 100 + '%',
      left: Math.random() * 100 + '%',
      zIndex: 2,
    },
  }
}

const Sparkle = ({ color, size, style }: Omit<SparkleProps, 'id' | 'createdAt'>) => {
  return (
    <motion.svg
      width={size}
      height={size}
      viewBox="0 0 160 160"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
      style={style}
      className="absolute pointer-events-none"
      initial={{ scale: 0, rotate: 0 }}
      animate={{
        scale: [0, 1, 0],
        rotate: [0, 180],
      }}
      transition={{
        duration: 1.5,
        repeat: Infinity,
        repeatDelay: Math.random() * 2,
      }}
    >
      <path
        d="M80 0C80 0 84.2846 41.2925 101.496 58.504C118.707 75.7154 160 80 160 80C160 80 118.707 84.2846 101.496 101.496C84.2846 118.707 80 160 80 160C80 160 75.7154 118.707 58.504 101.496C41.2925 84.2846 0 80 0 80C0 80 41.2925 75.7154 58.504 58.504C75.7154 41.2925 80 0 80 0Z"
        fill={color}
      />
    </motion.svg>
  )
}

export const Sparkles: React.FC<SparklesProps> = ({
  children,
  className,
  sparklesCount = 10,
  colors = DEFAULT_SPARKLE_COLORS,
}) => {
  const sparkles = useMemo(() => {
    return Array.from({ length: sparklesCount }, () => generateSparkle(colors))
  }, [sparklesCount, colors])

  return (
    <span className={cn('relative inline-block', className)}>
      {sparkles.map((sparkle) => (
        <Sparkle
          key={sparkle.id}
          color={sparkle.color}
          size={sparkle.size}
          style={sparkle.style}
        />
      ))}
      <span className="relative z-10">{children}</span>
    </span>
  )
}

Przykład użycia Sparkles:

Code
TypeScript
import { Sparkles } from '@/components/ui/sparkles'

export function SparklesPreview() {
  return (
    <div className="h-40 w-full bg-black flex items-center justify-center">
      <Sparkles
        sparklesCount={12}
        colors={{
          first: '#A07CFE',
          second: '#FE8FB5',
        }}
      >
        <span className="text-4xl font-bold text-white">
          Magical Text
        </span>
      </Sparkles>
    </div>
  )
}

Lamp Effect

Świecąca lampa z animowanym światłem - dramatyczny efekt hero.

TScomponents/ui/lamp.tsx
TypeScript
// components/ui/lamp.tsx
'use client'

import React from 'react'
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils'

export const LampContainer = ({
  children,
  className,
}: {
  children: React.ReactNode
  className?: string
}) => {
  return (
    <div
      className={cn(
        'relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-slate-950 w-full rounded-md z-0',
        className
      )}
    >
      <div className="relative flex w-full flex-1 scale-y-125 items-center justify-center isolate z-0">
        <motion.div
          initial={{ opacity: 0.5, width: '15rem' }}
          whileInView={{ opacity: 1, width: '30rem' }}
          transition={{
            delay: 0.3,
            duration: 0.8,
            ease: 'easeInOut',
          }}
          style={{
            backgroundImage: `conic-gradient(var(--conic-position), var(--tw-gradient-stops))`,
          }}
          className="absolute inset-auto right-1/2 h-56 overflow-visible w-[30rem] bg-gradient-conic from-cyan-500 via-transparent to-transparent text-white [--conic-position:from_70deg_at_center_top]"
        >
          <div className="absolute w-[100%] left-0 bg-slate-950 h-40 bottom-0 z-20 [mask-image:linear-gradient(to_top,white,transparent)]" />
          <div className="absolute w-40 h-[100%] left-0 bg-slate-950 bottom-0 z-20 [mask-image:linear-gradient(to_right,white,transparent)]" />
        </motion.div>
        <motion.div
          initial={{ opacity: 0.5, width: '15rem' }}
          whileInView={{ opacity: 1, width: '30rem' }}
          transition={{
            delay: 0.3,
            duration: 0.8,
            ease: 'easeInOut',
          }}
          style={{
            backgroundImage: `conic-gradient(var(--conic-position), var(--tw-gradient-stops))`,
          }}
          className="absolute inset-auto left-1/2 h-56 w-[30rem] bg-gradient-conic from-transparent via-transparent to-cyan-500 text-white [--conic-position:from_290deg_at_center_top]"
        >
          <div className="absolute w-40 h-[100%] right-0 bg-slate-950 bottom-0 z-20 [mask-image:linear-gradient(to_left,white,transparent)]" />
          <div className="absolute w-[100%] right-0 bg-slate-950 h-40 bottom-0 z-20 [mask-image:linear-gradient(to_top,white,transparent)]" />
        </motion.div>
        <div className="absolute top-1/2 h-48 w-full translate-y-12 scale-x-150 bg-slate-950 blur-2xl"></div>
        <div className="absolute top-1/2 z-50 h-48 w-full bg-transparent opacity-10 backdrop-blur-md"></div>
        <div className="absolute inset-auto z-50 h-36 w-[28rem] -translate-y-1/2 rounded-full bg-cyan-500 opacity-50 blur-3xl"></div>
        <motion.div
          initial={{ width: '8rem' }}
          whileInView={{ width: '16rem' }}
          transition={{
            delay: 0.3,
            duration: 0.8,
            ease: 'easeInOut',
          }}
          className="absolute inset-auto z-30 h-36 w-64 -translate-y-[6rem] rounded-full bg-cyan-400 blur-2xl"
        ></motion.div>
        <motion.div
          initial={{ width: '15rem' }}
          whileInView={{ width: '30rem' }}
          transition={{
            delay: 0.3,
            duration: 0.8,
            ease: 'easeInOut',
          }}
          className="absolute inset-auto z-50 h-0.5 w-[30rem] -translate-y-[7rem] bg-cyan-400"
        ></motion.div>

        <div className="absolute inset-auto z-40 h-44 w-full -translate-y-[12.5rem] bg-slate-950"></div>
      </div>

      <div className="relative z-50 flex -translate-y-80 flex-col items-center px-5">
        {children}
      </div>
    </div>
  )
}

Przykład użycia Lamp Effect:

Code
TypeScript
'use client'

import React from 'react'
import { motion } from 'framer-motion'
import { LampContainer } from '@/components/ui/lamp'

export function LampDemo() {
  return (
    <LampContainer>
      <motion.h1
        initial={{ opacity: 0.5, y: 100 }}
        whileInView={{ opacity: 1, y: 0 }}
        transition={{
          delay: 0.3,
          duration: 0.8,
          ease: 'easeInOut',
        }}
        className="mt-8 bg-gradient-to-br from-slate-300 to-slate-500 py-4 bg-clip-text text-center text-4xl font-medium tracking-tight text-transparent md:text-7xl"
      >
        Build lamps <br /> the right way
      </motion.h1>
    </LampContainer>
  )
}

Background Beams

Animowane wiązki świetlne w tle.

TScomponents/ui/background-beams.tsx
TypeScript
// components/ui/background-beams.tsx
'use client'

import React from 'react'
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils'

export const BackgroundBeams = ({ className }: { className?: string }) => {
  const paths = [
    'M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875',
    'M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867',
    'M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859',
    'M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851',
    'M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843',
    'M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835',
    'M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827',
    'M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819',
    'M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811',
    'M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803',
  ]

  return (
    <div
      className={cn(
        'absolute h-full w-full inset-0 [mask-size:40px] [mask-repeat:no-repeat] flex items-center justify-center',
        className
      )}
    >
      <svg
        className="z-0 h-full w-full pointer-events-none absolute"
        width="100%"
        height="100%"
        viewBox="0 0 696 316"
        fill="none"
        xmlns="http://www.w3.org/2000/svg"
      >
        {paths.map((path, index) => (
          <motion.path
            key={`path-` + index}
            d={path}
            stroke={`url(#linearGradient-${index})`}
            strokeOpacity="0.4"
            strokeWidth="0.5"
          ></motion.path>
        ))}
        <defs>
          {paths.map((_, index) => (
            <motion.linearGradient
              id={`linearGradient-${index}`}
              key={`gradient-${index}`}
              initial={{
                x1: '0%',
                x2: '0%',
                y1: '0%',
                y2: '0%',
              }}
              animate={{
                x1: ['0%', '100%'],
                x2: ['0%', '95%'],
                y1: ['0%', '100%'],
                y2: ['0%', `${93 + Math.random() * 8}%`],
              }}
              transition={{
                duration: Math.random() * 10 + 10,
                ease: 'easeInOut',
                repeat: Infinity,
                delay: Math.random() * 10,
              }}
            >
              <stop stopColor="#18CCFC" stopOpacity="0"></stop>
              <stop stopColor="#18CCFC"></stop>
              <stop offset="32.5%" stopColor="#6344F5"></stop>
              <stop offset="100%" stopColor="#AE48FF" stopOpacity="0"></stop>
            </motion.linearGradient>
          ))}
        </defs>
      </svg>
    </div>
  )
}

Text Generate Effect

Animowane generowanie tekstu litera po literze.

TScomponents/ui/text-generate-effect.tsx
TypeScript
// components/ui/text-generate-effect.tsx
'use client'

import { useEffect } from 'react'
import { motion, stagger, useAnimate } from 'framer-motion'
import { cn } from '@/lib/utils'

export const TextGenerateEffect = ({
  words,
  className,
  filter = true,
  duration = 0.5,
}: {
  words: string
  className?: string
  filter?: boolean
  duration?: number
}) => {
  const [scope, animate] = useAnimate()
  const wordsArray = words.split(' ')

  useEffect(() => {
    animate(
      'span',
      {
        opacity: 1,
        filter: filter ? 'blur(0px)' : 'none',
      },
      {
        duration: duration ? duration : 1,
        delay: stagger(0.2),
      }
    )
  }, [scope.current])

  const renderWords = () => {
    return (
      <motion.div ref={scope}>
        {wordsArray.map((word, idx) => {
          return (
            <motion.span
              key={word + idx}
              className="dark:text-white text-black opacity-0"
              style={{
                filter: filter ? 'blur(10px)' : 'none',
              }}
            >
              {word}{' '}
            </motion.span>
          )
        })}
      </motion.div>
    )
  }

  return (
    <div className={cn('font-bold', className)}>
      <div className="mt-4">
        <div className="dark:text-white text-black text-2xl leading-snug tracking-wide">
          {renderWords()}
        </div>
      </div>
    </div>
  )
}

Przykład użycia Text Generate Effect:

Code
TypeScript
import { TextGenerateEffect } from '@/components/ui/text-generate-effect'

const words = `Oxygen gets you high. In a catastrophic emergency, we're taking giant, panicked breaths. Suddenly you become euphoric, docile. You accept your fate.`

export function TextGenerateEffectDemo() {
  return <TextGenerateEffect words={words} />
}

Zaawansowane komponenty

Animated Tabs

TScomponents/ui/animated-tabs.tsx
TypeScript
// components/ui/animated-tabs.tsx
'use client'

import { useState } from 'react'
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils'

type Tab = {
  title: string
  value: string
  content?: string | React.ReactNode
}

export const Tabs = ({
  tabs: propTabs,
  containerClassName,
  activeTabClassName,
  tabClassName,
  contentClassName,
}: {
  tabs: Tab[]
  containerClassName?: string
  activeTabClassName?: string
  tabClassName?: string
  contentClassName?: string
}) => {
  const [active, setActive] = useState<Tab>(propTabs[0])
  const [tabs, setTabs] = useState<Tab[]>(propTabs)

  const moveSelectedTabToTop = (idx: number) => {
    const newTabs = [...propTabs]
    const selectedTab = newTabs.splice(idx, 1)
    newTabs.unshift(selectedTab[0])
    setTabs(newTabs)
    setActive(newTabs[0])
  }

  const [hovering, setHovering] = useState(false)

  return (
    <>
      <div
        className={cn(
          'flex flex-row items-center justify-start [perspective:1000px] relative overflow-auto sm:overflow-visible no-visible-scrollbar max-w-full w-full',
          containerClassName
        )}
      >
        {propTabs.map((tab, idx) => (
          <button
            key={tab.title}
            onClick={() => {
              moveSelectedTabToTop(idx)
            }}
            onMouseEnter={() => setHovering(true)}
            onMouseLeave={() => setHovering(false)}
            className={cn('relative px-4 py-2 rounded-full', tabClassName)}
            style={{
              transformStyle: 'preserve-3d',
            }}
          >
            {active.value === tab.value && (
              <motion.div
                layoutId="clickedbutton"
                transition={{ type: 'spring', bounce: 0.3, duration: 0.6 }}
                className={cn(
                  'absolute inset-0 bg-gray-200 dark:bg-zinc-800 rounded-full',
                  activeTabClassName
                )}
              />
            )}

            <span className="relative block text-black dark:text-white">
              {tab.title}
            </span>
          </button>
        ))}
      </div>
      <FadeInDiv
        tabs={tabs}
        active={active}
        key={active.value}
        hovering={hovering}
        className={cn('mt-32', contentClassName)}
      />
    </>
  )
}

export const FadeInDiv = ({
  className,
  tabs,
  hovering,
}: {
  className?: string
  key?: string
  tabs: Tab[]
  active: Tab
  hovering?: boolean
}) => {
  const isActive = (tab: Tab) => {
    return tab.value === tabs[0].value
  }
  return (
    <div className="relative w-full h-full">
      {tabs.map((tab, idx) => (
        <motion.div
          key={tab.value}
          layoutId={tab.value}
          style={{
            scale: 1 - idx * 0.1,
            top: hovering ? idx * -50 : 0,
            zIndex: -idx,
            opacity: idx < 3 ? 1 - idx * 0.1 : 0,
          }}
          animate={{
            y: isActive(tab) ? [0, 40, 0] : 0,
          }}
          className={cn('w-full h-full absolute top-0 left-0', className)}
        >
          {tab.content}
        </motion.div>
      ))}
    </div>
  )
}

Infinite Moving Cards

Nieskończenie przewijające się karty - idealne do testimoniali.

TScomponents/ui/infinite-moving-cards.tsx
TypeScript
// components/ui/infinite-moving-cards.tsx
'use client'

import { cn } from '@/lib/utils'
import React, { useEffect, useState } from 'react'

export const InfiniteMovingCards = ({
  items,
  direction = 'left',
  speed = 'fast',
  pauseOnHover = true,
  className,
}: {
  items: {
    quote: string
    name: string
    title: string
  }[]
  direction?: 'left' | 'right'
  speed?: 'fast' | 'normal' | 'slow'
  pauseOnHover?: boolean
  className?: string
}) => {
  const containerRef = React.useRef<HTMLDivElement>(null)
  const scrollerRef = React.useRef<HTMLUListElement>(null)

  useEffect(() => {
    addAnimation()
  }, [])

  const [start, setStart] = useState(false)

  function addAnimation() {
    if (containerRef.current && scrollerRef.current) {
      const scrollerContent = Array.from(scrollerRef.current.children)

      scrollerContent.forEach((item) => {
        const duplicatedItem = item.cloneNode(true)
        if (scrollerRef.current) {
          scrollerRef.current.appendChild(duplicatedItem)
        }
      })

      getDirection()
      getSpeed()
      setStart(true)
    }
  }

  const getDirection = () => {
    if (containerRef.current) {
      if (direction === 'left') {
        containerRef.current.style.setProperty(
          '--animation-direction',
          'forwards'
        )
      } else {
        containerRef.current.style.setProperty(
          '--animation-direction',
          'reverse'
        )
      }
    }
  }

  const getSpeed = () => {
    if (containerRef.current) {
      if (speed === 'fast') {
        containerRef.current.style.setProperty('--animation-duration', '20s')
      } else if (speed === 'normal') {
        containerRef.current.style.setProperty('--animation-duration', '40s')
      } else {
        containerRef.current.style.setProperty('--animation-duration', '80s')
      }
    }
  }

  return (
    <div
      ref={containerRef}
      className={cn(
        'scroller relative z-20 max-w-7xl overflow-hidden [mask-image:linear-gradient(to_right,transparent,white_20%,white_80%,transparent)]',
        className
      )}
    >
      <ul
        ref={scrollerRef}
        className={cn(
          'flex min-w-full shrink-0 gap-4 py-4 w-max flex-nowrap',
          start && 'animate-scroll',
          pauseOnHover && 'hover:[animation-play-state:paused]'
        )}
      >
        {items.map((item, idx) => (
          <li
            className="w-[350px] max-w-full relative rounded-2xl border border-b-0 flex-shrink-0 border-slate-700 px-8 py-6 md:w-[450px]"
            style={{
              background:
                'linear-gradient(180deg, var(--slate-800), var(--slate-900)',
            }}
            key={item.name + idx}
          >
            <blockquote>
              <div
                aria-hidden="true"
                className="user-select-none -z-1 pointer-events-none absolute -left-0.5 -top-0.5 h-[calc(100%_+_4px)] w-[calc(100%_+_4px)]"
              ></div>
              <span className="relative z-20 text-sm leading-[1.6] text-gray-100 font-normal">
                {item.quote}
              </span>
              <div className="relative z-20 mt-6 flex flex-row items-center">
                <span className="flex flex-col gap-1">
                  <span className="text-sm leading-[1.6] text-gray-400 font-normal">
                    {item.name}
                  </span>
                  <span className="text-sm leading-[1.6] text-gray-400 font-normal">
                    {item.title}
                  </span>
                </span>
              </div>
            </blockquote>
          </li>
        ))}
      </ul>
    </div>
  )
}

Tworzenie kompletnej strony z Aceternity UI

Przykład strony docelowej

TSapp/page.tsx
TypeScript
// app/page.tsx
import { Spotlight } from '@/components/ui/spotlight'
import { TextGenerateEffect } from '@/components/ui/text-generate-effect'
import { CardContainer, CardBody, CardItem } from '@/components/ui/3d-card'
import { InfiniteMovingCards } from '@/components/ui/infinite-moving-cards'
import { Meteors } from '@/components/ui/meteors'

const testimonials = [
  {
    quote: "This product changed the way we work. Absolutely amazing experience!",
    name: "Sarah Johnson",
    title: "CEO at TechCorp",
  },
  {
    quote: "Best investment we've made this year. The results speak for themselves.",
    name: "Michael Chen",
    title: "CTO at StartupXYZ",
  },
  {
    quote: "Incredible customer support and outstanding product quality.",
    name: "Emily Williams",
    title: "Product Manager at InnovateCo",
  },
]

const features = [
  {
    title: "Lightning Fast",
    description: "Optimized for speed with cutting-edge technology",
    icon: "⚡",
  },
  {
    title: "Secure by Default",
    description: "Enterprise-grade security built into every feature",
    icon: "🔒",
  },
  {
    title: "AI Powered",
    description: "Smart automation that learns and adapts to your needs",
    icon: "🤖",
  },
]

export default function LandingPage() {
  return (
    <main className="min-h-screen bg-black/[0.96] antialiased bg-grid-white/[0.02]">
      {/* Hero Section with Spotlight */}
      <section className="h-screen w-full flex md:items-center md:justify-center relative overflow-hidden">
        <Spotlight
          className="-top-40 left-0 md:left-60 md:-top-20"
          fill="white"
        />
        <div className="p-4 max-w-7xl mx-auto relative z-10 w-full pt-20 md:pt-0">
          <h1 className="text-4xl md:text-7xl font-bold text-center bg-clip-text text-transparent bg-gradient-to-b from-neutral-50 to-neutral-400">
            Build Amazing
            <br />
            Products Faster
          </h1>
          <TextGenerateEffect
            words="Transform your ideas into reality with our cutting-edge platform.
                   Start building today and see results tomorrow."
            className="mt-8 text-center max-w-2xl mx-auto"
          />
          <div className="flex gap-4 justify-center mt-10">
            <button className="px-8 py-3 rounded-full bg-white text-black font-semibold hover:bg-gray-200 transition">
              Get Started
            </button>
            <button className="px-8 py-3 rounded-full border border-white/20 text-white hover:bg-white/10 transition">
              Learn More
            </button>
          </div>
        </div>
      </section>

      {/* Features Section with 3D Cards */}
      <section className="py-20 px-4">
        <h2 className="text-3xl md:text-5xl font-bold text-center text-white mb-16">
          Powerful Features
        </h2>
        <div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-6xl mx-auto">
          {features.map((feature, idx) => (
            <CardContainer key={idx} className="inter-var">
              <CardBody className="bg-gray-900 relative group/card border-white/[0.1] w-full h-auto rounded-xl p-6 border">
                <CardItem
                  translateZ="50"
                  className="text-4xl mb-4"
                >
                  {feature.icon}
                </CardItem>
                <CardItem
                  translateZ="60"
                  className="text-xl font-bold text-white"
                >
                  {feature.title}
                </CardItem>
                <CardItem
                  as="p"
                  translateZ="80"
                  className="text-neutral-400 text-sm mt-2"
                >
                  {feature.description}
                </CardItem>
              </CardBody>
            </CardContainer>
          ))}
        </div>
      </section>

      {/* Testimonials Section */}
      <section className="py-20 relative">
        <h2 className="text-3xl md:text-5xl font-bold text-center text-white mb-16">
          What Our Customers Say
        </h2>
        <InfiniteMovingCards
          items={testimonials}
          direction="right"
          speed="slow"
        />
      </section>

      {/* CTA Section with Meteors */}
      <section className="py-20 px-4 relative overflow-hidden">
        <div className="max-w-2xl mx-auto relative">
          <div className="absolute inset-0 h-full w-full bg-gradient-to-r from-blue-500 to-teal-500 transform scale-[0.80] rounded-full blur-3xl" />
          <div className="relative shadow-xl bg-gray-900 border border-gray-800 px-8 py-12 rounded-2xl overflow-hidden">
            <h2 className="text-3xl font-bold text-white text-center mb-4">
              Ready to Get Started?
            </h2>
            <p className="text-neutral-400 text-center mb-8">
              Join thousands of satisfied customers today.
            </p>
            <div className="flex justify-center">
              <button className="px-8 py-3 rounded-full bg-gradient-to-r from-blue-500 to-teal-500 text-white font-semibold hover:opacity-90 transition">
                Start Free Trial
              </button>
            </div>
            <Meteors number={20} />
          </div>
        </div>
      </section>
    </main>
  )
}

Dobre praktyki

1. Optymalizacja wydajności

Code
TypeScript
// Lazy loading komponentów wizualnych
import dynamic from 'next/dynamic'

const Spotlight = dynamic(
  () => import('@/components/ui/spotlight').then((mod) => mod.Spotlight),
  { ssr: false }
)

const Meteors = dynamic(
  () => import('@/components/ui/meteors').then((mod) => mod.Meteors),
  { ssr: false }
)

2. Responsywność

Code
TypeScript
// Dostosowanie efektów do urządzeń mobilnych
export function ResponsiveSpotlight() {
  const isMobile = useMediaQuery('(max-width: 768px)')

  return (
    <div className="relative">
      {/* Spotlight tylko na desktop */}
      {!isMobile && (
        <Spotlight className="-top-40 left-0 md:left-60 md:-top-20" fill="white" />
      )}
      <div className="relative z-10">
        {/* Content */}
      </div>
    </div>
  )
}

3. Dostępność

Code
TypeScript
// Dodanie reduced motion support
const prefersReducedMotion = usePrefersReducedMotion()

<motion.div
  initial={{ opacity: 0, y: 20 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{
    duration: prefersReducedMotion ? 0 : 0.8,
  }}
>
  {children}
</motion.div>

Koszt wydajnościowy, którego nie widać na demo

Strona z demonstracjami działa u autora rewelacyjnie, bo prezentuje jeden komponent naraz na mocnym laptopie. Prawdziwa strona sprzedażowa składa kilka takich efektów jeden pod drugim i wtedy zaczynają się pytania, których żadne demo nie zada.

Pierwszy problem to koszt paczki. Sam pakiet animacji to kilkadziesiąt kilobajtów po kompresji, ale prawdziwy narzut rośnie z każdym komponentem, który wnosi własny kod. Efekt aury, meteory, iskierki i karta 3D razem potrafią dołożyć więcej do pierwszego ładowania niż cała reszta strony. Na łączu światłowodowym nikt tego nie zauważy, na telefonie w gorszym zasięgu owszem.

Drugi to malowanie. Efekty oparte o duże rozmycia, czyli aurora, poświaty i lampa, wymuszają na przeglądarce przeliczanie sporych obszarów ekranu przy każdej klatce. Filtr blur o promieniu kilkudziesięciu pikseli rozciągnięty na cały ekran to jedna z najdroższych operacji, jakie da się zlecić przeglądarce w CSS. Na komputerze z dedykowaną grafiką jest to niewidoczne, na średnim telefonie z Androidem widać spadek płynności przy przewijaniu.

Trzeci to komponenty wykonujące pracę przy każdym ruchu myszy. Karta 3D przelicza pozycję kursora i ustawia transformację bezpośrednio na elemencie. Dopóki takich kart jest trzy, wszystko jest w porządku. Przy siatce dwunastu kart w widoku zaczyna to być odczuwalne, zwłaszcza że każda instancja trzyma własny stan i własny kontekst.

Praktyczne wnioski są trzy i wszystkie sprowadzają się do umiaru. Jeden mocny efekt na ekran wystarcza, dwa już konkurują o uwagę. Ciężkie tła warto wyłączać na urządzeniach mobilnych, a nie tylko skalować. Komponenty, które nie są widoczne od razu, ładuj dynamicznie, żeby nie blokowały pierwszego renderowania.

Osobna uwaga należy się nazwie. To, co ta biblioteka nazywa efektem trójwymiarowym, jest przekształceniem CSS: element przechyla się za kursorem, ale nadal jest płaskim prostokątem. Prawdziwa scena renderowana przez kartę graficzną to inna kategoria narzędzi, na przykład Spline, i inny rząd kosztów: kilka megabajtów do pobrania i renderowanie w pętli przez cały czas, gdy scena jest widoczna. Jeśli szukasz wrażenia głębi na stronie sprzedażowej, przechylenie w CSS jest zwykle właściwym wyborem, bo kosztuje ułamek tamtego.

Dostępność, czyli największa luka tej biblioteki

Trzeba to powiedzieć wprost, bo dokumentacja tego nie mówi. Aceternity UI nie zajmuje się dostępnością i nie udaje, że się zajmuje. Komponenty nie mają atrybutów opisujących rolę, obsługi klawiatury ani zarządzania fokusem. Zakładka animowana przełącza się kliknięciem i nie odpowie na strzałki. To nie jest błąd biblioteki, tylko konsekwencja jej przeznaczenia: to warstwa dekoracyjna, a nie warstwa interakcji.

Konkretny obowiązek, który spada na Ciebie, dotyczy preferencji ograniczenia ruchu. Część użytkowników ma w systemie ustawione, że nie chce animacji, zwykle z powodu zawrotów głowy albo migreny. Przeglądarka wystawia tę informację przez zapytanie medialne i to Twoje zadanie, żeby ją uszanować.

Code
CSS
@media (prefers-reduced-motion: reduce) {
  .animate-aurora,
  .animate-meteor-effect,
  .animate-spotlight {
    animation: none;
  }
}

Cztery linijki w globalnym arkuszu załatwiają temat dla efektów opartych o klatki kluczowe. Dla komponentów sterowanych z poziomu JavaScriptu potrzebny jest hak sprawdzający tę samą preferencję i zerujący czas trwania animacji, co pokazuje sekcja o dobrych praktykach powyżej.

Drugi obowiązek dotyczy kontrastu. Tekst na animowanym tle bywa czytelny w jednej fazie animacji i nieczytelny w innej. Sprawdzaj kontrast w najgorszym momencie cyklu, nie na zrzucie ekranu zrobionym w przypadkowej chwili.

Licencja i warstwa płatna

Darmowa część biblioteki obejmuje komponenty dostępne na stronie i można ich używać w projektach komercyjnych bez opłat. Ponieważ kopiujesz kod do siebie, nie ma tu mechanizmu klucza licencyjnego ani weryfikacji przy budowaniu, w odróżnieniu od części bibliotek płatnych, gdzie brak klucza blokuje uruchomienie produkcyjne.

Osobno sprzedawane są gotowe szablony i bloki Pro, czyli złożone całe strony zamiast pojedynczych komponentów. Obok darmowego poziomu cennik ma trzy warianty płatne: dostęp roczny za 169 dolarów, dostęp bezterminowy za jednorazowe 199 dolarów i wariant zespołowy dla dziesięciu osób za jednorazowe 1590 dolarów. Każda z tych kwot jest w chwili pisania obniżona wobec, odpowiednio, 249, 299 i 1990 dolarów, a dostawca nie podaje daty końca obniżki, więc przed zakupem sprawdź stawki bezpośrednio. Zakres jest w każdym wariancie ten sam, ponad dwieście płatnych bloków i kilkanaście szablonów, a różni je okres aktualizacji: rok w wariancie rocznym, przyszłe wydania bez ograniczenia czasu w bezterminowym. Zwrotów dostawca nie przewiduje, bo zakup od razu odsłania cały kod.

Warto natomiast wiedzieć, za co się w ogóle płaci, bo to pytanie wraca. Płatna jest kompozycja, nie technologia. Darmowe komponenty dają Ci klocki, płatne szablony dają ułożoną z nich stronę wraz z decyzjami o odstępach, typografii i kolejności sekcji. Jeśli masz projektanta albo dobre wyczucie układu, warstwa płatna niewiele Ci doda. Jeśli składasz stronę sam i widzisz, że wychodzi z tego zlepek efektów bez rytmu, to właśnie tam siedzi wartość.

Typowe błędy

Najczęstszy dotyczy brakującej dyrektywy 'use client'. Komponenty korzystają ze stanu, referencji i animacji uruchamianych w przeglądarce, więc w Next.js muszą być komponentami klienckimi. Błąd objawia się komunikatem o użyciu haka w komponencie serwerowym i bywa mylnie przypisywany bibliotece.

Drugi to skopiowanie komponentu bez dopisania klatek kluczowych do konfiguracji Tailwinda. Kod się kompiluje, komponent się renderuje, animacja po prostu nie startuje, bo klasa animate-aurora nie istnieje. Zawsze przenoś obie części naraz: plik komponentu i wpisy z sekcji keyframes.

Trzeci to konflikt klas przy dopisywaniu własnych stylów. Funkcja cn istnieje właśnie po to, żeby scalać klasy Tailwinda z zachowaniem pierwszeństwa, i pominięcie jej kończy się tym, że Twoje p-8 przegrywa z wbudowanym p-6 w sposób zależny od kolejności w arkuszu. Więcej o tej mechanice znajdziesz przy okazji Tailwind CSS.

Czwarty to losowość liczona podczas renderowania. Komponent meteorów wylicza pozycje przez generator liczb losowych, więc serwer i przeglądarka dostają różne wyniki, a React zgłasza niezgodność drzewa. Rozwiązaniem jest wyliczenie pozycji w efekcie po zamontowaniu albo wyłączenie renderowania serwerowego dla tego komponentu.

Piąty to traktowanie tych komponentów jak stabilnego interfejsu. Skopiowałeś kod, więc masz jego wersję z konkretnego dnia. Przy aktualizacji Reacta czy Motion to Ty odpowiadasz za sprawdzenie, czy nic się nie posypało, i nikt Cię o tym nie powiadomi.

FAQ

Czy Aceternity UI jest darmowe?

Darmowa jest cała biblioteka komponentów widoczna na stronie i można jej używać komercyjnie bez opłat. Płatna jest osobna warstwa gotowych szablonów i bloków Pro, czyli złożonych stron zamiast pojedynczych komponentów.

Czy komponenty działają z React Server Components?

Nie bezpośrednio. Prawie każdy komponent tej biblioteki potrzebuje dyrektywy 'use client', bo korzysta ze stanu, referencji do elementów albo zdarzeń myszy. Możesz je importować w komponentach serwerowych, ale sam plik komponentu musi być kliencki.

Czy trzeba przepisać framer-motion na motion?

Nie ma takiej potrzeby. Obie nazwy pakietu publikują się równolegle w tej samej wersji, więc istniejące importy działają dalej. W nowych projektach używaj nazwy motion, bo to nazwa bieżąca i pod nią prowadzona jest dokumentacja.

Czy te komponenty są dostępne dla użytkowników klawiatury?

Nie, biblioteka nie zajmuje się dostępnością. Nie ma tu obsługi klawiatury, zarządzania fokusem ani atrybutów opisujących rolę elementów. Do interfejsu, w którym to ma znaczenie, użyj shadcn/ui albo Radix UI, a Aceternity zostaw warstwie dekoracyjnej.

Czy da się użyć Aceternity UI poza Next.js?

Tak, komponenty to zwykły React z Tailwindem, więc działają w projekcie na Vite czy w dowolnej innej konfiguracji. Jedyne, co odpada, to przykłady korzystające z komponentu obrazu z Next.js, gdzie wystarczy podmienić go na zwykły znacznik.

Pełną listę komponentów znajdziesz w dokumentacji Aceternity UI, a szczegóły dotyczące animacji w dokumentacji Motion dla Reacta.