Vice
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.
motionfree
"use client";
import { animate, motion, useMotionValue } from "motion/react";
import * as React from "react";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/viceManual — install dependencies, then copy the source
npm install motionProps
| 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 all props of Omit<React.ComponentPropsWithoutRef<"section">, "onChange"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.