Getting started
Installation
Three steps: Tailwind v4, one CSS import, then copy components — by hand, with the offline script, or through the shadcn CLI.
Requirements
- React 19 and Tailwind CSS v4 (CSS-first config). Next.js 15/16 is the reference setup; Vite + React works the same.
- A path alias
@/*is not required — OPUS files import each other relatively.
npx create-next-app@latest my-app --ts --tailwind --app
cd my-app1 · Add the theme
Install the two tiny helpers, copy components/opus/opus.css into your project and import it right after Tailwind in your global stylesheet. It defines shadcn-compatible tokens (light + dark), radius scale, easing curves and the keyframes some components use.
npm i clsx tailwind-merge@import "tailwindcss";
@import "../components/opus/opus.css";/* =====================================================================
* OPUS UI — theme tokens + keyframes (Tailwind CSS v4)
*
* Paste once into your global stylesheet, right after `@import "tailwindcss";`
* @import "../components/opus/opus.css";
*
* Token names follow the shadcn/ui convention, so OPUS components drop
* straight into a shadcn project (and vice versa). Dark mode is class based:
* put `class="dark"` on <html>; `class="light"` re-forces light for a subtree.
* ===================================================================== */
/* `.light` on any element forces light tokens for that subtree (and turns `dark:` off inside it) */
@custom-variant dark (&:where(.dark, .dark *):not(:where(.light, .light *)));
:root,
.light {
--radius: 0.75rem;
--background: oklch(0.992 0.002 270);
--foreground: oklch(0.18 0.012 272);
--card: oklch(1 0 0);
--card-foreground: var(--foreground);
--popover: oklch(1 0 0);
--popover-foreground: var(--foreground);
--primary: oklch(0.55 0.23 279);
--primary-foreground: oklch(0.99 0.005 280);
--secondary: oklch(0.955 0.006 272);
--secondary-foreground: oklch(0.24 0.014 272);
--muted: oklch(0.962 0.005 272);
--muted-foreground: oklch(0.52 0.016 272);
--accent: oklch(0.95 0.018 280);
--accent-foreground: oklch(0.3 0.08 280);
--destructive: oklch(0.6 0.21 25);
--destructive-foreground: oklch(0.99 0 0);
--success: oklch(0.63 0.16 158);
--success-foreground: oklch(0.99 0 0);
--warning: oklch(0.76 0.155 72);
--warning-foreground: oklch(0.25 0.05 70);
--info: oklch(0.62 0.16 242);
--info-foreground: oklch(0.99 0 0);
--border: oklch(0.912 0.007 272);
--input: oklch(0.885 0.009 272);
--ring: oklch(0.55 0.23 279 / 0.55);
--shadow-color: 250 30% 20%;
}
.dark {
--background: oklch(0.145 0.009 276);
--foreground: oklch(0.965 0.004 272);
--card: oklch(0.172 0.011 276);
--card-foreground: var(--foreground);
--popover: oklch(0.19 0.012 276);
--popover-foreground: var(--foreground);
--primary: oklch(0.67 0.2 281);
--primary-foreground: oklch(0.16 0.03 280);
--secondary: oklch(0.235 0.012 276);
--secondary-foreground: oklch(0.94 0.005 272);
--muted: oklch(0.215 0.011 276);
--muted-foreground: oklch(0.69 0.016 272);
--accent: oklch(0.26 0.03 280);
--accent-foreground: oklch(0.95 0.02 280);
--destructive: oklch(0.64 0.2 24);
--success: oklch(0.7 0.15 158);
--success-foreground: oklch(0.16 0.03 160);
--warning: oklch(0.8 0.14 75);
--info: oklch(0.7 0.14 240);
--info-foreground: oklch(0.16 0.03 240);
--border: oklch(1 0 0 / 0.085);
--input: oklch(1 0 0 / 0.13);
--ring: oklch(0.67 0.2 281 / 0.6);
--shadow-color: 260 50% 2%;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
}
@theme {
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out-quart: cubic-bezier(0.76, 0, 0.24, 1);
--animate-fade-in: opus-fade-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
--animate-fade-out: opus-fade-out 0.15s ease-in both;
--animate-scale-in: opus-scale-in 0.22s cubic-bezier(0.16, 1, 0.3, 1) both;
--animate-scale-out: opus-scale-out 0.14s ease-in both;
--animate-slide-up: opus-slide-up 0.32s cubic-bezier(0.16, 1, 0.3, 1) both;
--animate-slide-in-right: opus-slide-in-right 0.36s cubic-bezier(0.16, 1, 0.3, 1) both;
--animate-slide-in-left: opus-slide-in-left 0.36s cubic-bezier(0.16, 1, 0.3, 1) both;
--animate-slide-in-bottom: opus-slide-in-bottom 0.36s cubic-bezier(0.16, 1, 0.3, 1) both;
--animate-shimmer: opus-shimmer 2.4s linear infinite;
--animate-marquee: opus-marquee var(--duration, 30s) linear infinite;
--animate-marquee-y: opus-marquee-y var(--duration, 30s) linear infinite;
--animate-spin-slow: spin 6s linear infinite;
--animate-caret: opus-caret 1.1s steps(1) infinite;
--animate-meteor: opus-meteor var(--duration, 6s) linear infinite;
--animate-pulse-ring: opus-pulse-ring 1.8s cubic-bezier(0.16, 1, 0.3, 1) infinite;
--animate-gradient-pan: opus-gradient-pan 6s linear infinite;
--animate-float: opus-float 6s ease-in-out infinite;
--animate-indeterminate: opus-indeterminate 1.4s cubic-bezier(0.76, 0, 0.24, 1) infinite;
@keyframes opus-fade-in { from { opacity: 0 } to { opacity: 1 } }
@keyframes opus-fade-out { from { opacity: 1 } to { opacity: 0 } }
@keyframes opus-scale-in { from { opacity: 0; transform: scale(0.96) translateY(4px) } to { opacity: 1; transform: none } }
@keyframes opus-scale-out { from { opacity: 1; transform: none } to { opacity: 0; transform: scale(0.97) } }
@keyframes opus-slide-up { from { opacity: 0; transform: translateY(12px) } to { opacity: 1; transform: none } }
@keyframes opus-slide-in-right { from { transform: translateX(100%) } to { transform: none } }
@keyframes opus-slide-in-left { from { transform: translateX(-100%) } to { transform: none } }
@keyframes opus-slide-in-bottom { from { transform: translateY(100%) } to { transform: none } }
@keyframes opus-shimmer { from { background-position: 200% 0 } to { background-position: -200% 0 } }
@keyframes opus-marquee { from { transform: translateX(0) } to { transform: translateX(calc(-100% - var(--gap, 1rem))) } }
@keyframes opus-marquee-y { from { transform: translateY(0) } to { transform: translateY(calc(-100% - var(--gap, 1rem))) } }
@keyframes opus-caret { 0%, 100% { opacity: 1 } 50% { opacity: 0 } }
@keyframes opus-meteor {
0% { transform: rotate(var(--angle, 215deg)) translateX(0); opacity: 1 }
70% { opacity: 1 }
100% { transform: rotate(var(--angle, 215deg)) translateX(-600px); opacity: 0 }
}
@keyframes opus-pulse-ring { 0% { transform: scale(0.8); opacity: 0.8 } 100% { transform: scale(2.2); opacity: 0 } }
@keyframes opus-gradient-pan { from { background-position: 0% 50% } to { background-position: 200% 50% } }
@keyframes opus-float { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(-10px) } }
@keyframes opus-indeterminate { 0% { transform: translateX(-100%) scaleX(0.4) } 50% { transform: translateX(30%) scaleX(0.6) } 100% { transform: translateX(110%) scaleX(0.3) } }
}
/* ---------- helpers used by several components ---------- */
@utility scrollbar-none {
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
@utility mask-fade-x {
mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
}
@utility mask-fade-y {
mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent);
}
@utility mask-radial {
mask-image: radial-gradient(ellipse at center, #000 30%, transparent 72%);
}
@layer base {
* {
border-color: var(--color-border);
}
:focus-visible {
outline-color: var(--color-ring);
}
}
@media (prefers-reduced-motion: reduce) {
.motion-safe-only {
animation: none !important;
transition: none !important;
}
}Want your own colours? Generate the :root / .dark blocks in the theme builder and paste them over the defaults.
2 · The cn helper
Every component merges classes with cn(), so your className always wins over the defaults.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge class names; later Tailwind classes win over earlier ones. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}3 · Add components
Fastest: run the offline script from your OPUS checkout. It resolves every relative import, copies the files into components/opus/, skips files you already have and tells you which npm packages to install.
node ../tailwind_component/opus55/scripts/add.mjs button dialog aurora --cwd .npx shadcn@latest add https://opusui.131199999.xyz/r/aurora.jsonOr open any component page, switch the install tab to Manual and copy the files shown — they are the exact files the scripts would write.
Dark mode
Dark mode is class based: add dark to <html> (or let next-themes do it with attribute="class"). Backgrounds take explicit colours, so they look identical in both themes.
<html lang="en" className="dark">Your first page
import { Aurora } from "@/components/opus/backgrounds/aurora";
import { Button } from "@/components/opus/ui/button";
export default function Page() {
return (
<section className="relative h-[80vh]">
<Aurora className="absolute inset-0" />
<div className="relative z-10 grid h-full place-items-center">
<Button variant="glow" size="lg">Get started</Button>
</div>
</section>
);
}