OPUS/UI

Navigation

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-app

1 · 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
app/globals.css
@import "tailwindcss";
@import "../components/opus/opus.css";
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.

components/opus/lib/cn.ts
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.json

Or 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

app/page.tsx
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>
  );
}

Command palette