# Vice — Theme for React

> A self-contained synthwave theme preset — card, buttons, stat, and tag in hot-magenta → cyan neon on deep indigo, with a sunset gradient, a scanlined sun rising over a perspective grid, and a chrome-italic display heading. A keyboard-operable radiogroup of vibe chips re-tokens the whole panel live; every glow pins to a bold static frame under reduced motion.

- Page: https://crucibleui.com/components/themes/vice
- Category: [themes](https://crucibleui.com/components/themes)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/vice
```

Registry JSON: https://crucibleui.com/r/vice.json

Import from `@/components/crucible/vice`.

## Props (`ViceProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant?` | `string` | — | Controlled active preset id (matches a preset's `id`). |
| `defaultVariant?` | `string` | `"miami" (first preset)` | Initial preset id when uncontrolled. |
| `onVariantChange?` | `(id: string) => void` | — | Fires with the next preset id whenever the switcher changes. |
| `presets?` | `VicePreset[]` | `the three built-in vibes` | Override the built-in preset set (chips + palettes). |
| `colors?` | `Partial<VicePalette>` | — | Token overrides merged over the active preset — makes any palette reachable. |
| `speed?` | `number` | `1` | Glow-pulse and grid-scroll speed multiplier. |
| `intensity?` | `number` | `1` | Neon glow strength multiplier. |
| `paused?` | `boolean` | `false` | Freeze all animation at the resting neon frame. |
| `tagline?` | `string` | `"TRACK 07"` | Small mono label above the heading. |
| `heading?` | `string` | `"AFTERGLOW"` | Chrome-italic display heading. |
| `description?` | `string` | `"Neon on deep indigo. Pick a vibe."` | Body line under the heading. |
| `statValue?` | `string` | `"128"` | Stat figure. |
| `statLabel?` | `string` | `"BPM"` | Stat unit / label. |
| `tag?` | `string` | `"SIDE A"` | Small pill tag. |
| `primaryLabel?` | `string` | `"Play"` | Filled button label. |
| `secondaryLabel?` | `string` | `"Queue"` | Outline button label. |
| `label?` | `string` | `"Synthwave theme preset"` | Accessible label for the panel region. |
| `switcherLabel?` | `string` | `"Theme vibe"` | Accessible label for the switcher group. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"section">, "onChange">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Vice } from "@/components/crucible/vice";

export default function ViceDemo() {
  const [variant, setVariant] = React.useState("miami");

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950 p-6">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,45,149,0.06),transparent_70%)]"
      />
      <div className="relative z-10 flex flex-col items-center gap-5">
        <Vice variant={variant} onVariantChange={setVariant} />
        <p className="text-center text-sm text-neutral-500">
          Switch the vibe — click a chip, or focus it and use the arrow keys.
        </p>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`vice.tsx`)

```tsx
"use client";

import { animate, motion, useMotionValue } from "motion/react";
import * as React from "react";

import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { cn } from "@/lib/utils";

export interface VicePalette {
  /** Deep panel base — the indigo night behind everything. */
  base: string;
  /** Upper sunset band stop. */
  horizon: string;
  /** Lower sunset band stop, just above the grid. */
  horizonLow: string;
  /** Primary neon — heading glow, filled buttons, the horizon grid. */
  primary: string;
  /** Secondary neon accent — outlines, stat value, tagline glow. */
  secondary: string;
  /** Sun disc top color. */
  sun: string;
  /** Foreground text. */
  text: string;
  /** Muted / secondary text. */
  muted: string;
}

export interface VicePreset {
  /** Stable id used for controlled selection and React keys. */
  id: string;
  /** Chip label shown in the switcher. */
  label: string;
  /** Token set applied when this preset is active. */
  palette: VicePalette;
}

/** Built-in vibes — a spectrum from balanced magenta/cyan to hot red/amber to cool violet. */
const PRESETS: VicePreset[] = [
  {
    id: "miami",
    label: "Miami",
    palette: {
      base: "#160b2e",
      horizon: "#ff5db1",
      horizonLow: "#7d1e9c",
      primary: "#ff2d95",
      secondary: "#2de2e6",
      sun: "#ffcf5c",
      text: "#f6e9ff",
      muted: "#b79fd4",
    },
  },
  {
    id: "outrun",
    label: "Outrun",
    palette: {
      base: "#1a0730",
      horizon: "#ff7a3d",
      horizonLow: "#a01f7a",
      primary: "#ff2d55",
      secondary: "#ffcf3d",
      sun: "#ff8a3d",
      text: "#ffeede",
      muted: "#d09fb0",
    },
  },
  {
    id: "twilight",
    label: "Twilight",
    palette: {
      base: "#0d0322",
      horizon: "#7b2ff7",
      horizonLow: "#3a1078",
      primary: "#b46bff",
      secondary: "#4cc9f0",
      sun: "#e05fd6",
      text: "#ece7ff",
      muted: "#9d97cf",
    },
  },
];

export interface ViceProps
  extends Omit<React.ComponentPropsWithoutRef<"section">, "onChange"> {
  /** Controlled active preset id (matches a preset's `id`). */
  variant?: string;
  /** Initial preset id when uncontrolled. @default "miami" (first preset) */
  defaultVariant?: string;
  /** Fires with the next preset id whenever the switcher changes. */
  onVariantChange?: (id: string) => void;
  /** Override the built-in preset set (chips + palettes). @default the three built-in vibes */
  presets?: VicePreset[];
  /** Token overrides merged over the active preset — makes any palette reachable. */
  colors?: Partial<VicePalette>;
  /** Glow-pulse and grid-scroll speed multiplier. @default 1 */
  speed?: number;
  /** Neon glow strength multiplier. @default 1 */
  intensity?: number;
  /** Freeze all animation at the resting neon frame. @default false */
  paused?: boolean;
  /** Small mono label above the heading. @default "TRACK 07" */
  tagline?: string;
  /** Chrome-italic display heading. @default "AFTERGLOW" */
  heading?: string;
  /** Body line under the heading. @default "Neon on deep indigo. Pick a vibe." */
  description?: string;
  /** Stat figure. @default "128" */
  statValue?: string;
  /** Stat unit / label. @default "BPM" */
  statLabel?: string;
  /** Small pill tag. @default "SIDE A" */
  tag?: string;
  /** Filled button label. @default "Play" */
  primaryLabel?: string;
  /** Outline button label. @default "Queue" */
  secondaryLabel?: string;
  /** Accessible label for the panel region. @default "Synthwave theme preset" */
  label?: string;
  /** Accessible label for the switcher group. @default "Theme vibe" */
  switcherLabel?: string;
}

const GRID_CELL = 34;

/**
 * Vice — a self-contained synthwave / retrowave theme preset. A card, buttons,
 * stat, and tag painted in hot-magenta → cyan neon on deep indigo, with a
 * sunset gradient, a scanlined sun rising over a perspective grid, and a
 * chrome-italic display heading. A keyboard-operable radiogroup of vibe chips
 * re-tokens the whole panel live; the palette is fully exposed via `presets`
 * and `colors`. Every glow drives off one CSS-variable pulse, so reduced motion
 * simply pins it to a bold static neon frame — no pulsing, no grid scroll.
 */
export function Vice({
  variant,
  defaultVariant,
  onVariantChange,
  presets,
  colors,
  speed = 1,
  intensity = 1,
  paused = false,
  tagline = "TRACK 07",
  heading = "AFTERGLOW",
  description = "Neon on deep indigo. Pick a vibe.",
  statValue = "128",
  statLabel = "BPM",
  tag = "SIDE A",
  primaryLabel = "Play",
  secondaryLabel = "Queue",
  label = "Synthwave theme preset",
  switcherLabel = "Theme vibe",
  className,
  style,
  ...rest
}: ViceProps) {
  const reducedMotion = useReducedMotion();

  const list = presets && presets.length > 0 ? presets : PRESETS;
  const isControlled = variant !== undefined;
  const [internal, setInternal] = React.useState(
    defaultVariant ?? list[0].id
  );
  const activeId = isControlled ? variant : internal;
  const active =
    list.find((p) => p.id === activeId) ?? list[0];
  const t: VicePalette = { ...active.palette, ...colors };

  const spd = Math.max(0.1, speed);
  const amt = Math.max(0, intensity);

  // One pulse (scaled by intensity) modulates every glow via a CSS variable;
  // grid scroll is a second variable. Colors live in their own vars so
  // switching vibes recolors instantly even while the pulse is frozen. Reduced
  // motion pins the pulse to full intensity — a bold static neon frame.
  const pulse = useMotionValue(amt);
  const shift = useMotionValue(0);

  React.useEffect(() => {
    if (reducedMotion || paused) {
      pulse.set(amt);
      shift.set(0);
      return;
    }
    const p = animate(pulse, [amt, 0.62 * amt, amt], {
      duration: 3.4 / spd,
      repeat: Infinity,
      ease: "easeInOut",
    });
    const g = animate(shift, GRID_CELL, {
      duration: 2.8 / spd,
      repeat: Infinity,
      ease: "linear",
    });
    return () => {
      p.stop();
      g.stop();
    };
  }, [reducedMotion, paused, spd, amt, pulse, shift]);

  const activeIdx = Math.max(
    0,
    list.findIndex((p) => p.id === activeId)
  );
  const chipRefs = React.useRef<(HTMLButtonElement | null)[]>([]);

  const select = React.useCallback(
    (i: number) => {
      const next = list[i];
      if (!next) return;
      if (!isControlled) setInternal(next.id);
      onVariantChange?.(next.id);
    },
    [list, isControlled, onVariantChange]
  );

  const onSwitcherKey = React.useCallback(
    (e: React.KeyboardEvent<HTMLDivElement>) => {
      let next = activeIdx;
      switch (e.key) {
        case "ArrowRight":
        case "ArrowDown":
          next = (activeIdx + 1) % list.length;
          break;
        case "ArrowLeft":
        case "ArrowUp":
          next = (activeIdx - 1 + list.length) % list.length;
          break;
        case "Home":
          next = 0;
          break;
        case "End":
          next = list.length - 1;
          break;
        default:
          return;
      }
      e.preventDefault();
      select(next);
      chipRefs.current[next]?.focus();
    },
    [activeIdx, list, select]
  );

  // Colors are plain CSS vars (recolor instantly on switch); --vpulse and
  // --vshift are motion values, so this element must be a motion component.
  const vars = {
    "--vp": t.primary,
    "--vs": t.secondary,
    "--vbase": t.base,
    "--vhorizon": t.horizon,
    "--vhorizonlow": t.horizonLow,
    "--vsun": t.sun,
    "--vtext": t.text,
    "--vmuted": t.muted,
    "--vpulse": pulse,
    "--vshift": shift,
    background: "var(--vbase)",
  } as React.CSSProperties;

  const focusRing =
    "outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--vs)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--vbase)]";

  return (
    <section
      aria-label={label}
      data-crucible="vice"
      data-variant={active.id}
      className={cn("relative isolate w-full max-w-sm", className)}
      style={style}
      {...rest}
    >
      <motion.div
        className="relative isolate flex w-full flex-col overflow-hidden rounded-2xl"
        style={vars}
      >
      {/* Hero sky — sunset, scanlined sun, perspective grid. Decorative. */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-x-0 top-0 z-0 h-[58%] overflow-hidden"
      >
        <div
          className="absolute inset-0"
          style={{
            background:
              "linear-gradient(180deg, var(--vbase) 0%, var(--vhorizon) 62%, var(--vhorizonlow) 100%)",
          }}
        />
        {/* Sun */}
        <div
          className="absolute left-1/2 bottom-[30%] h-28 w-28 -translate-x-1/2 overflow-hidden rounded-full"
          style={{
            background: "linear-gradient(180deg, var(--vsun) 0%, var(--vp) 100%)",
            boxShadow:
              "0 0 calc(var(--vpulse) * 60px) color-mix(in srgb, var(--vp) 55%, transparent)",
          }}
        >
          <div
            className="absolute inset-x-0 bottom-0 top-1/2"
            style={{
              background:
                "repeating-linear-gradient(180deg, transparent 0 5px, var(--vbase) 5px 8px)",
              maskImage: "linear-gradient(180deg, transparent, #000 90%)",
              WebkitMaskImage: "linear-gradient(180deg, transparent, #000 90%)",
            }}
          />
        </div>
        {/* Perspective grid */}
        <motion.div
          className="absolute inset-x-[-25%] bottom-0 top-[62%]"
          style={{
            backgroundImage:
              "linear-gradient(var(--vp) 1px, transparent 1px), linear-gradient(90deg, var(--vp) 1px, transparent 1px)",
            backgroundSize: `${GRID_CELL}px ${GRID_CELL}px`,
            backgroundPosition: "center calc(var(--vshift) * 1px)",
            transform: "perspective(150px) rotateX(66deg)",
            transformOrigin: "center bottom",
            opacity: 0.55,
            maskImage: "linear-gradient(180deg, transparent, #000 45%)",
            WebkitMaskImage: "linear-gradient(180deg, transparent, #000 45%)",
          }}
        />
        {/* Blend the hero into the base so content stays legible. */}
        <div
          className="absolute inset-0"
          style={{
            background:
              "linear-gradient(180deg, transparent 45%, var(--vbase) 100%)",
          }}
        />
      </div>

      {/* Content */}
      <div className="relative z-10 flex flex-col gap-4 p-5 pt-8">
        <div className="flex flex-col gap-1">
          <p
            className="font-mono text-[0.62rem] font-semibold uppercase tracking-[0.32em]"
            style={{
              color: "var(--vs)",
              textShadow:
                "0 0 8px color-mix(in srgb, var(--vs) 60%, transparent)",
            }}
          >
            {tagline}
          </p>
          <motion.h3
            className="text-4xl font-extrabold italic leading-none tracking-tight font-stretch-expanded"
            style={{
              backgroundImage:
                "linear-gradient(180deg, #ffffff 0%, var(--vs) 42%, var(--vtext) 50%, var(--vp) 100%)",
              WebkitBackgroundClip: "text",
              backgroundClip: "text",
              WebkitTextFillColor: "transparent",
              color: "transparent",
              filter:
                "drop-shadow(0 0 calc(var(--vpulse) * 14px) var(--vp)) drop-shadow(0 0 calc(var(--vpulse) * 5px) var(--vs))",
            }}
          >
            {heading}
          </motion.h3>
          <p className="text-sm" style={{ color: "var(--vmuted)" }}>
            {description}
          </p>
        </div>

        {/* Stat + tag */}
        <div className="flex items-center gap-3">
          <div className="flex items-baseline gap-1.5">
            <span
              className="text-2xl font-bold tabular-nums"
              style={{
                color: "var(--vp)",
                textShadow:
                  "0 0 calc(var(--vpulse) * 10px) color-mix(in srgb, var(--vp) 60%, transparent)",
              }}
            >
              {statValue}
            </span>
            <span
              className="font-mono text-[0.6rem] uppercase tracking-widest"
              style={{ color: "var(--vmuted)" }}
            >
              {statLabel}
            </span>
          </div>
          <span
            className="ml-auto rounded-full px-2.5 py-0.5 font-mono text-[0.6rem] font-semibold uppercase tracking-widest"
            style={{
              color: "var(--vs)",
              border: "1px solid color-mix(in srgb, var(--vs) 55%, transparent)",
            }}
          >
            {tag}
          </span>
        </div>

        {/* Buttons */}
        <div className="flex gap-3">
          <button
            type="button"
            className={cn(
              "rounded-md px-4 py-2 text-sm font-semibold transition-transform active:scale-[0.97]",
              focusRing
            )}
            style={{
              background: "var(--vp)",
              color: "var(--vbase)",
              boxShadow:
                "0 0 calc(var(--vpulse) * 13px) color-mix(in srgb, var(--vp) 65%, transparent)",
            }}
          >
            {primaryLabel}
          </button>
          <button
            type="button"
            className={cn(
              "rounded-md px-4 py-2 text-sm font-semibold transition-transform active:scale-[0.97]",
              focusRing
            )}
            style={{
              color: "var(--vs)",
              border: "1px solid var(--vs)",
              boxShadow:
                "0 0 calc(var(--vpulse) * 11px) color-mix(in srgb, var(--vs) 45%, transparent)",
            }}
          >
            {secondaryLabel}
          </button>
        </div>

        {/* Vibe switcher — a real radiogroup with roving focus. */}
        <div
          role="radiogroup"
          aria-label={switcherLabel}
          onKeyDown={onSwitcherKey}
          className="mt-1 flex flex-wrap gap-2"
        >
          {list.map((p, i) => {
            const selected = p.id === active.id;
            return (
              <button
                key={p.id}
                ref={(el) => {
                  chipRefs.current[i] = el;
                }}
                type="button"
                role="radio"
                aria-checked={selected}
                tabIndex={selected ? 0 : -1}
                onClick={() => select(i)}
                className={cn(
                  "rounded-full px-3 py-1 text-xs font-semibold transition-transform active:scale-[0.97]",
                  focusRing
                )}
                style={
                  selected
                    ? {
                        background: "var(--vp)",
                        color: "var(--vbase)",
                        boxShadow:
                          "0 0 12px color-mix(in srgb, var(--vp) 55%, transparent)",
                      }
                    : {
                        color: "var(--vmuted)",
                        border:
                          "1px solid color-mix(in srgb, var(--vmuted) 45%, transparent)",
                      }
                }
              >
                {p.label}
              </button>
            );
          })}
        </div>
      </div>

      {/* Neon border ring — pulses with everything else. */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 z-20 rounded-2xl"
        style={{
          border: "1px solid color-mix(in srgb, var(--vp) 50%, transparent)",
          boxShadow:
            "0 0 calc(var(--vpulse) * 16px) color-mix(in srgb, var(--vp) 55%, transparent), inset 0 0 calc(var(--vpulse) * 14px) color-mix(in srgb, var(--vs) 22%, transparent)",
        }}
      />
      </motion.div>
    </section>
  );
}
```
