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
| Cecha | Aceternity UI | Magic UI | shadcn/ui | React Bits |
|---|---|---|---|---|
| Główny obszar | efekty 3D i tła | mikroanimacje i sekcje | komponenty aplikacji | efekty tekstowe i tła |
| Model dystrybucji | kopiowanie kodu | kopiowanie kodu | kopiowanie kodu | kopiowanie kodu |
| Zależność od Motion | wymagana | wymagana | opcjonalna | częściowa |
| Dostępność i obsługa klawiatury | poza zakresem | poza zakresem | priorytet | poza zakresem |
| Warstwa płatna | tak, szablony Pro | tak, szablony Pro | nie | nie |
| Typowe zastosowanie | strona sprzedażowa | strona sprzedażowa | interfejs produktu | strona 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ć.
npm install motion clsx tailwind-merge
npm install @tabler/icons-react
npm install simplex-noisePakiet 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.
// 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()
// 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.
// 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:
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.
// 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:
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't know what else to write so I'll
just keep writing gibberish.
</p>
</div>
</div>
)
}Meteors Effect
Spadające meteory w tle - efekt wizualny inspirowany kosmosem.
// 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:
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're cool
</h1>
<p className="font-normal text-base text-slate-500 mb-4 relative z-50">
I don't know what to write so I'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.
// 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:
'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.
// 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:
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.
// 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:
'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.
// 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.
// 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:
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
// 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.
// 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
// 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
// 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ść
// 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ść
// 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ć.
@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.