nexus-labs
componentreal-time-cursorssource: stale · stale
Real-Time Cursors
Collaborative workspace preview with animated teammate cursors, code review cards, and simulated live presence trails.
npx shadcn add "https://nexus-ui.com/r/real-time-cursors.json"free
This source snapshot is older than our freshness window and couldn't be re-verified just now — it may be outdated.
Where it goes
testimonial
Visual style
terminal-mono
Interaction
cursor-follow
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationframer-motion
iconslucide-react
Registry dependencies
https://www.nexus-ui.com/api/registry/nexus-fontutils
Files10 · source cached 10/7/2026, 3:51:38 PM
next-ui/real-time-cursors/index.ts
export { RealTimeCursors } from "./real-time-cursors";
export type { LiveCursor, RealTimeCursorsProps } from "./real-time-cursors";
next-ui/real-time-cursors/real-time-cursors.tsx
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { Code2, MessageSquareText, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import { CodeBlock } from "@/components/code-block/code-block";
import { ChatMessages } from "@/components/chat-messages";
import { CardStackRotator } from "@/components/card-stack-rotator/card-stack-rotator";
import type { CardData } from "@/components/card-stack-rotator/card-stack-rotator";
import { EncryptedText } from "@/components/encrypted-text";
import { TypewriterEffect } from "@/components/typewriter-effect";
// ── Types ─────────────────────────────────────────────────────────────────────
export type LiveCursor = {
name: string;
color: string;
textColor?: string;
path: Array<{ x: number; y: number }>;
duration?: number;
delay?: number;
note?: string;
};
export type RealTimeCursorsProps = {
cursors?: LiveCursor[];
className?: string;
};
// ── Cursor data ───────────────────────────────────────────────────────────────
const defaultCursors: LiveCursor[] = [
{
name: "Ava",
color: "#2563eb",
path: [
{ x: 10, y: 15 },
{ x: 48, y: 8 },
{ x: 68, y: 35 },
{ x: 28, y: 52 },
{ x: 14, y: 32 },
],
duration: 11,
},
{
name: "Noah",
color: "#7c3aed",
path: [
{ x: 82, y: 68 },
{ x: 58, y: 42 },
{ x: 38, y: 60 },
{ x: 90, y: 28 },
{ x: 72, y: 80 },
],
duration: 12,
delay: 0.9,
},
{
name: "Mira",
color: "#059669",
path: [
{ x: 22, y: 78 },
{ x: 52, y: 55 },
{ x: 78, y: 72 },
{ x: 36, y: 20 },
{ x: 60, y: 12 },
],
duration: 10,
delay: 1.8,
},
{
name: "Kai",
color: "#e11d48",
path: [
{ x: 88, y: 12 },
{ x: 64, y: 28 },
{ x: 42, y: 6 },
{ x: 18, y: 28 },
{ x: 50, y: 46 },
],
duration: 13,
delay: 2.6,
},
];
// ── Static demo data ──────────────────────────────────────────────────────────
const CARDS: CardData[] = [
{
id: "1",
content: "**Real-time collaboration** changes how teams build products together.",
name: "Jordan Lee",
designation: "Head of Product",
initials: "JL",
company: "Nexus",
},
{
id: "2",
content: "Design systems need **consistency and flexibility** in equal measure.",
name: "Alex Chen",
designation: "Lead Designer",
initials: "AC",
company: "Studio",
},
{
id: "3",
content: "The best **developer experience** feels invisible — it just works.",
name: "Maya Patel",
designation: "Engineering Lead",
initials: "MP",
company: "Nexus",
},
];
// Defined at module level — inline array literals create a new ref each render,
// which resets the TypewriterEffect animation before it can progress.
const TYPEWRITER_TEXTS = [
"A stunning landing page.",
"A real-time dashboard.",
"A collaborative workspace.",
"The next big thing.",
];
const SNIPPET = `import { RealTimeCursors } from "@nexus/ui";
export default function Canvas() {
return (
<RealTimeCursors
cursors={[
{ name: "Ava", color: "#2563eb",
path: [{ x: 10, y: 15 }, ...] },
{ name: "Noah", color: "#7c3aed",
path: [{ x: 82, y: 68 }, ...] },
]}
/>
);
}`;
// ── Cursor SVG ────────────────────────────────────────────────────────────────
function CursorIcon({ color, size = 22 }: { color: string; size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="drop-shadow-[0_8px_14px_rgba(0,0,0,.3)]"
>
<path
d="M5.4 3.1v16.2c0 .52.64.77.99.39l4.2-4.52c.14-.15.34-.24.55-.24h6.76c.6 0 .9-.73.48-1.15L6.55 2.63c-.43-.4-1.15-.1-1.15.47Z"
fill={color}
stroke="rgba(255,255,255,.86)"
strokeWidth="1.4"
/>
</svg>
);
}
function LiveCursorMarker({ cursor }: { cursor: LiveCursor }) {
const xs = cursor.path.map((p) => `${p.x}%`);
const ys = cursor.path.map((p) => `${p.y}%`);
return (
<motion.div
className="absolute left-0 top-0 z-30 pointer-events-none"
style={{ x: "-50%", y: "-50%" }}
initial={{ left: xs[0], top: ys[0], opacity: 0 }}
animate={{ left: xs, top: ys, opacity: [0, 1, 1, 1, 0] }}
transition={{
duration: cursor.duration ?? 11,
repeat: Infinity,
repeatType: "mirror",
ease: "easeInOut",
delay: cursor.delay ?? 0,
}}
>
<div className="relative">
<CursorIcon color={cursor.color} />
<motion.div
className="absolute left-6 top-5 flex items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-1 text-[11px] font-semibold shadow-lg"
style={{ backgroundColor: cursor.color, color: cursor.textColor ?? "#ffffff" }}
animate={{ y: [0, -2, 0] }}
transition={{ duration: 2.2, repeat: Infinity, ease: "easeInOut", delay: cursor.delay ?? 0 }}
>
{cursor.name}
</motion.div>
</div>
</motion.div>
);
}
// ── Grid Cell ─────────────────────────────────────────────────────────────────
function GridCell({
children,
className,
label,
accentColor = "rgba(255,255,255,0.05)",
}: {
children: React.ReactNode;
className?: string;
label?: string;
accentColor?: string;
}) {
return (
<div
className={cn(
"relative overflow-hidden rounded-2xl border border-white/10 bg-[#080e22]/90 backdrop-blur",
className,
)}
style={{ boxShadow: `inset 0 0 50px ${accentColor}` }}
>
{label && (
<div className="absolute top-3 left-3 z-10 rounded-full border border-white/10 bg-black/50 px-2.5 py-0.5 text-[10px] font-medium text-white/35 backdrop-blur pointer-events-none">
{label}
</div>
)}
{children}
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export function RealTimeCursors({ cursors = defaultCursors, className }: RealTimeCursorsProps) {
return (
<div
className={cn(
"relative isolate w-full overflow-hidden rounded-3xl border border-white/10 bg-[#030610] p-5 text-white",
className,
)}
>
{/* Ambient blobs */}
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_8%_8%,rgba(59,130,246,.14),transparent_38%),radial-gradient(ellipse_at_92%_85%,rgba(124,58,237,.12),transparent_38%),radial-gradient(ellipse_at_50%_50%,rgba(16,185,129,.06),transparent_55%)]" />
{/* Fine grid */}
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(rgba(255,255,255,.025)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.025)_1px,transparent_1px)] bg-[size:44px_44px]" />
{/* ── Header ── */}
<div className="relative z-10 mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-white/55">
<MessageSquareText className="size-3.5" />
Multiplayer workspace
</div>
<h3 className="mt-2 text-2xl font-semibold tracking-tight text-white sm:text-3xl">
Live collaboration canvas
</h3>
</div>
<div className="flex items-center gap-2">
<div className="flex -space-x-2">
{cursors.map((c) => (
<div
key={c.name}
className="grid size-8 place-items-center rounded-full border-2 border-[#030610] text-[11px] font-bold"
style={{ backgroundColor: c.color }}
>
{c.name[0]}
</div>
))}
</div>
<div className="flex items-center gap-1.5 rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1 text-xs font-medium text-emerald-300">
<span className="size-1.5 rounded-full bg-emerald-400 animate-pulse" />
<Users className="size-3" />
{cursors.length} online
</div>
</div>
</div>
{/* ── Bento grid — cursors float over everything ── */}
<div className="relative">
<div className="pointer-events-none absolute inset-0 z-20">
{cursors.map((c) => (
<LiveCursorMarker key={c.name} cursor={c} />
))}
</div>
{/*
4-column grid, 4 rows — each cell a different size:
Row 1 (380px): [CodeBlock — 2col] [ChatMessages — 2col]
Row 2 (320px): [CardStack — 1col] [EncryptedText — 1col] [Typewriter — 2col]
Row 3 (240px): [ShootingStars — 3col] [LiveCursors spotlight — 1col]
*/}
<div className="grid grid-cols-4 gap-3">
{/* ── Row 1 ── */}
{/* CodeBlock — col span 2, tall */}
<GridCell className="col-span-2 h-[380px]" label="code-block" accentColor="rgba(250,204,21,0.05)">
<div className="h-full overflow-hidden p-3 pt-8">
<CodeBlock
code={SNIPPET}
language="tsx"
theme="dark"
header={{
enabled: true,
showDots: true,
title: "canvas.tsx",
copy: { enabled: true },
}}
codeArea={{
showLineNumbers: true,
background: "transparent",
}}
border={{ color: "rgba(255,255,255,0.07)", radius: "12px" }}
className="h-full"
/>
</div>
</GridCell>
{/* ChatMessages — col span 2, tall */}
<GridCell className="col-span-2 h-[380px]" label="chat-messages" accentColor="rgba(99,102,241,0.07)">
<div className="h-full pt-8">
<ChatMessages
autoPlay
autoPlayDelay={900}
typingDuration={1200}
showReplay
interactive={false}
className="h-full"
/>
</div>
</GridCell>
{/* ── Row 2 ── */}
{/* CardStackRotator — col span 1 */}
{/* overflow-visible so back-card y-offset isn't clipped by the cell border */}
<GridCell className="col-span-1 h-[320px] !overflow-visible" label="card-stack-rotator" accentColor="rgba(139,92,246,0.07)">
<div className="flex h-full items-start justify-center px-4 pb-4 pt-10">
{/* wrapper height = cardHeight + stackOffset so the back card fits exactly */}
<div className="relative w-full" style={{ height: "calc(180px + 16px)" }}>
<CardStackRotator
cards={CARDS}
rotationInterval={3500}
maxVisible={2}
stackOffset={16}
scaleStep={0.04}
cardBackground="#ffffff"
cardBorderColor="rgba(0,0,0,0.08)"
textColor="#0f172a"
mutedColor="rgba(0,0,0,0.45)"
highlightBackground="rgba(139,92,246,0.12)"
highlightTextColor="#7c3aed"
cardWidth="100%"
cardHeight="180px"
className="w-full"
/>
</div>
</div>
</GridCell>
{/* EncryptedText — col span 1 */}
<GridCell className="col-span-1 h-[320px]" label="encrypted-text" accentColor="rgba(16,185,129,0.07)">
<div className="flex h-full flex-col items-center justify-center gap-6 p-6 pt-8 text-center">
<EncryptedText
text="NEXUS UI"
trigger="viewport"
loop
loopDelay={2500}
speed={60}
shuffleSpeed={30}
className="text-3xl font-black tracking-widest text-emerald-400"
/>
<EncryptedText
text="Build the future"
trigger="viewport"
loop
loopDelay={3000}
speed={45}
revealDelay={400}
className="text-base font-medium text-white/50"
/>
<EncryptedText
text="component by component"
trigger="viewport"
loop
loopDelay={3500}
speed={40}
revealDelay={800}
className="text-sm text-white/30"
/>
</div>
</GridCell>
{/* TypewriterEffect — col span 2 */}
<GridCell className="col-span-2 h-[320px]" label="typewriter-effect" accentColor="rgba(251,113,133,0.07)">
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 pt-10 text-center">
<p className="text-[10px] font-medium uppercase tracking-widest text-white/30">What will you build?</p>
<TypewriterEffect
text={TYPEWRITER_TEXTS}
typingSpeed={55}
deletingSpeed={35}
pauseDuration={1800}
loop
showCursor
as="h2"
className="text-3xl font-bold text-white sm:text-4xl"
/>
<p className="mt-2 max-w-xs text-sm text-white/40 leading-relaxed">
Nexus UI ships production-ready components so you can focus on what matters.
</p>
</div>
</GridCell>
</div>
</div>
{/* Footer */}
<div className="pointer-events-none mt-4 flex items-center justify-end gap-2 text-[11px] text-white/25">
<Code2 className="size-3" />
Cursor paths are simulated locally
</div>
</div>
);
}
export default RealTimeCursors;
next-ui/code-block/code-block.tsx
"use client";
import * as React from "react";
import { motion, AnimatePresence } from "framer-motion";
// ── Types ─────────────────────────────────────────────────────────────────────
export type CodeLanguage =
| "plain"
| "js"
| "ts"
| "jsx"
| "tsx"
| "css"
| "json"
| "html";
export type HeaderProps = {
enabled?: boolean;
showDots?: boolean;
background?: string;
title?: string;
font?: React.CSSProperties;
titleColor?: string;
copy?: CopyProps;
};
export type CopyProps = {
enabled?: boolean;
label?: string;
copiedLabel?: string;
copiedDurationMs?: number;
color?: string;
accentColor?: string;
};
export type CodeAreaProps = {
showLineNumbers?: boolean;
wrap?: boolean;
padding?: number;
background?: string;
selectionColor?: string;
textColor?: string;
subtleTextColor?: string;
font?: React.CSSProperties;
};
export type BorderProps = {
color?: string;
width?: number;
radius?: string;
};
export type CodeBlockProps = {
code?: string;
language?: CodeLanguage;
theme?: "dark" | "light" | "custom";
header?: HeaderProps;
codeArea?: CodeAreaProps;
border?: BorderProps;
className?: string;
style?: React.CSSProperties;
};
// ── Defaults ──────────────────────────────────────────────────────────────────
const DEFAULT_CODE = `import React, { useState } from 'react';
export default function PricingCard() {
const [isYearly, setIsYearly] = useState(false);
return (
<div className="pricing-card">
<h3>Pro Plan</h3>
<p className="price">
\${isYearly ? '99' : '12'}
<span className="duration">
/{isYearly ? 'year' : 'month'}
</span>
</p>
<button onClick={() => setIsYearly(!isYearly)}>
Toggle Billing
</button>
<ul className="features">
<li>Unlimited Projects</li>
<li>Priority Support</li>
</ul>
</div>
);
}`;
export const DEFAULT_HEADER: HeaderProps = {
enabled: true,
showDots: true,
background: "#1A1A1A",
title: "Example",
titleColor: "#E5E5E5",
copy: {
enabled: true,
label: "Copy",
copiedLabel: "Copied",
copiedDurationMs: 1200,
color: "#888888",
accentColor: "#E5E5E5",
},
};
export const DEFAULT_CODE_AREA: CodeAreaProps = {
showLineNumbers: true,
wrap: true,
padding: 16,
background: "#0D0D0D",
selectionColor: "rgba(255, 255, 255, 0.1)",
textColor: "#D4D4D4",
subtleTextColor: "#555555",
};
export const DEFAULT_BORDER: BorderProps = {
color: "#1A1A1A",
width: 1,
radius: "12px",
};
// ── Theme colors ───────────────────────────────────────────────────────────────
type ActiveColors = {
border: string;
containerBg: string;
headerBg: string;
title: string;
copy: string;
copyAccent: string;
codeBg: string;
text: string;
subtleText: string;
selection: string;
isDark: boolean;
};
function isColorDark(color: string): boolean {
if (!color) return false;
let r = 0, g = 0, b = 0;
const c = color.trim();
if (c.startsWith("#")) {
let hex = c.replace("#", "");
if (hex.length === 3) hex = hex.split("").map(x => x + x).join("");
r = parseInt(hex.substring(0, 2), 16) || 0;
g = parseInt(hex.substring(2, 4), 16) || 0;
b = parseInt(hex.substring(4, 6), 16) || 0;
} else if (c.startsWith("rgb")) {
const match = c.match(/\d+(\.\d+)?/g);
if (match && match.length >= 3) {
r = parseInt(match[0], 10);
g = parseInt(match[1], 10);
b = parseInt(match[2], 10);
}
}
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance < 0.5;
}
function setAlpha(color: string, alpha: number): string {
if (!color) return "transparent";
const c = color.trim();
if (c.startsWith("#")) {
let hex = c.replace("#", "");
if (hex.length === 3) hex = hex.split("").map(x => x + x).join("");
if (hex.length === 6) {
const a = Math.round(alpha * 255).toString(16).padStart(2, "0");
return `#${hex}${a}`;
}
if (hex.length === 8) return c;
}
if (c.startsWith("rgb")) {
const m = c.match(/\d+(\.\d+)?/g);
if (m && m.length >= 3) {
return `rgba(${m[0]}, ${m[1]}, ${m[2]}, ${alpha})`;
}
}
return c;
}
// ── Token types ───────────────────────────────────────────────────────────────
type TokenType =
| "keyword"
| "function"
| "class"
| "string"
| "number"
| "comment"
| "operator"
| "punct"
| "tag"
| "attr"
| "value"
| "boolean"
| "nullish"
| "plain";
type Token = { t: TokenType; v: string };
// ── Token color map ────────────────────────────────────────────────────────────
function tokenColor(t: TokenType, baseColor: string, isDark: boolean): string {
if (isDark) {
switch (t) {
case "keyword": return "#D2A8FF";
case "function": return "#82AAFF";
case "class": return "#FFCB6B";
case "string": return "#C3E88D";
case "number": return "#F78C6C";
case "comment": return "#7A7A7A";
case "operator": return "#89DDFF";
case "punct": return "#D4D4D4";
case "tag": return "#F07178";
case "attr": return "#FFCB6B";
case "value": return "#C3E88D";
case "boolean": return "#F78C6C";
case "nullish": return "#F78C6C";
default: return baseColor;
}
}
switch (t) {
case "keyword": return "#8839EF";
case "function": return "#1E66F5";
case "class": return "#DF8E1D";
case "string": return "#40A02B";
case "number": return "#FE640B";
case "comment": return "#8C8FA1";
case "operator": return "#04A5E5";
case "punct": return "#4C4F69";
case "tag": return "#D20F39";
case "attr": return "#DF8E1D";
case "value": return "#40A02B";
case "boolean": return "#FE640B";
case "nullish": return "#FE640B";
default: return baseColor;
}
}
// ── Tokenizer ──────────────────────────────────────────────────────────────────
function isDigit(ch: string): boolean {
return ch >= "0" && ch <= "9";
}
function isIdentStart(ch: string): boolean {
return (ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || ch === "_" || ch === "$";
}
function isIdentPart(ch: string): boolean {
return isIdentStart(ch) || isDigit(ch);
}
function tokenizeCodeLine(line: string): Token[] {
return tokenizeGeneric(line, {
keywords: [
"const", "let", "var", "function", "return", "if", "else", "for", "while",
"do", "switch", "case", "break", "continue", "try", "catch", "finally",
"throw", "new", "class", "extends", "super", "this", "import", "from",
"export", "default", "type", "interface", "implements", "public", "private",
"protected", "readonly", "as", "async", "await", "yield", "in", "of",
"instanceof", "typeof", "void", "delete", "get", "set", "static",
],
keywordType: () => "keyword" as TokenType,
commentStyle: "line+block",
});
}
function tokenizeJsonLine(line: string): Token[] {
return tokenizeGeneric(line, {
keywords: ["true", "false", "null"],
keywordType: (w: string) => w === "null" ? "nullish" : "boolean",
commentStyle: "none",
});
}
function tokenizeCssLine(line: string): Token[] {
return tokenizeGeneric(line, {
keywords: [],
keywordType: () => "keyword" as TokenType,
commentStyle: "block",
});
}
function tokenizeHtmlLine(line: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < line.length) {
const ch = line[i];
if (ch === "<") {
const close = line.indexOf(">", i + 1);
const end = close === -1 ? line.length : close + 1;
const segment = line.slice(i, end);
const m = segment.match(/^<\/?\s*([a-zA-Z0-9:-]+)/);
if (m) {
const tagName = m[1];
const prefixLen = segment.indexOf(tagName);
if (prefixLen > 0) tokens.push({ t: "punct", v: segment.slice(0, prefixLen) });
tokens.push({ t: "tag", v: tagName });
const rest = segment.slice(prefixLen + tagName.length);
tokens.push(...tokenizeHtmlAttrs(rest));
} else {
tokens.push({ t: "tag", v: segment });
}
i = end;
continue;
}
const next = line.indexOf("<", i);
const end = next === -1 ? line.length : next;
tokens.push({ t: "plain", v: line.slice(i, end) });
i = end;
}
return tokens;
}
function tokenizeHtmlAttrs(rest: string): Token[] {
const tokens: Token[] = [];
let s = rest;
while (s.length) {
const quoteIdx = s.search(/["']/);
if (quoteIdx === -1) {
tokens.push({ t: "punct", v: s });
break;
}
if (quoteIdx > 0) {
tokens.push(...tokenizeAttrNames(s.slice(0, quoteIdx)));
s = s.slice(quoteIdx);
}
const q = s[0];
const end = s.indexOf(q, 1);
const v = end === -1 ? s : s.slice(0, end + 1);
tokens.push({ t: "value", v });
s = end === -1 ? "" : s.slice(end + 1);
}
return tokens;
}
function tokenizeAttrNames(text: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < text.length) {
const m = text.slice(i).match(/^[\s/=>]+/);
if (m) {
tokens.push({ t: "punct", v: m[0] });
i += m[0].length;
continue;
}
const n = text.slice(i).match(/^[a-zA-Z_:][-a-zA-Z0-9_:.]*/);
if (n) {
tokens.push({ t: "attr", v: n[0] });
i += n[0].length;
continue;
}
tokens.push({ t: "punct", v: text[i] });
i++;
}
return tokens;
}
type GenericOpts = {
keywords: string[];
keywordType: (w: string) => TokenType;
commentStyle: "none" | "line" | "block" | "line+block";
};
function tokenizeGeneric(line: string, opts: GenericOpts): Token[] {
const tokens: Token[] = [];
const kw = new Set(opts.keywords);
let i = 0;
const push = (t: TokenType, v: string) => {
if (!v) return;
tokens.push({ t, v });
};
while (i < line.length) {
const ch = line[i];
if (
(opts.commentStyle === "line" || opts.commentStyle === "line+block") &&
ch === "/" && line[i + 1] === "/"
) {
push("comment", line.slice(i));
break;
}
if (
(opts.commentStyle === "block" || opts.commentStyle === "line+block") &&
ch === "/" && line[i + 1] === "*"
) {
const end = line.indexOf("*/", i + 2);
const seg = end === -1 ? line.slice(i) : line.slice(i, end + 2);
push("comment", seg);
i += seg.length;
continue;
}
if (ch === '"' || ch === "'" || ch === "`") {
const q = ch;
let j = i + 1;
let escaped = false;
while (j < line.length) {
const cj = line[j];
if (escaped) { escaped = false; }
else if (cj === "\\") { escaped = true; }
else if (cj === q) { j++; break; }
j++;
}
push("string", line.slice(i, j));
i = j;
continue;
}
if (isDigit(ch) || (ch === "." && isDigit(line[i + 1] ?? ""))) {
let j = i + 1;
while (j < line.length && /[0-9._eE+-]/.test(line[j])) j++;
push("number", line.slice(i, j));
i = j;
continue;
}
if (isIdentStart(ch)) {
let j = i + 1;
while (j < line.length && isIdentPart(line[j])) j++;
const word = line.slice(i, j);
let k = j;
while (k < line.length && (line[k] === " " || line[k] === "\t")) k++;
const isFunctionCall = line[k] === "(";
const isUppercase = /^[A-Z]/.test(word);
if (kw.has(word)) push(opts.keywordType(word), word);
else if (word === "true" || word === "false") push("boolean", word);
else if (word === "null" || word === "undefined") push("nullish", word);
else if (isFunctionCall) push("function", word);
else if (isUppercase) push("class", word);
else push("plain", word);
i = j;
continue;
}
if (/[=+\-*/%<>!&|^~?:]/.test(ch)) {
const two = line.slice(i, i + 2);
const three = line.slice(i, i + 3);
if (["===", "!==", ">>>"].includes(three)) {
push("operator", three);
i += 3;
continue;
}
if (["==", "!=", "<=", ">=", "=>", "&&", "||", "??", "++", "--", "+=", "-=", "*=", "/="].includes(two)) {
push("operator", two);
i += 2;
continue;
}
push("operator", ch);
i++;
continue;
}
if (/[()[\]{},.;]/.test(ch)) {
push("punct", ch);
i++;
continue;
}
if (ch === " " || ch === "\t") {
let j = i + 1;
while (j < line.length && (line[j] === " " || line[j] === "\t")) j++;
push("plain", line.slice(i, j));
i = j;
continue;
}
push("plain", ch);
i++;
}
return tokens.length ? tokens : [{ t: "plain", v: " " }];
}
function tokenizeLine(line: string, language: CodeLanguage): Token[] | null {
if (language === "plain") return null;
if (language === "html") return tokenizeHtmlLine(line);
if (language === "json") return tokenizeJsonLine(line);
if (language === "css") return tokenizeCssLine(line);
return tokenizeCodeLine(line);
}
// ── Icons ──────────────────────────────────────────────────────────────────────
function WindowDot({ color }: { color: string }) {
return (
<span
style={{
width: 10,
height: 10,
borderRadius: 999,
background: color,
display: "inline-block",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.08)",
}}
/>
);
}
function CopyIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M8 7.5V7a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-.5"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
<path
d="M6 9h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2Z"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
</svg>
);
}
// ── HighlightedLine ────────────────────────────────────────────────────────────
function HighlightedLine({
tokens,
baseColor,
isDark,
}: {
tokens: Token[];
baseColor: string;
isDark: boolean;
}) {
return (
<>
{tokens.map((tok, idx) => (
<span
key={idx}
style={{ color: tokenColor(tok.t, baseColor, isDark) }}
>
{tok.v.length > 0 ? tok.v : " "}
</span>
))}
</>
);
}
// ── Main Component ────────────────────────────────────────────────────────────
export function CodeBlock({
code = DEFAULT_CODE,
language = "jsx",
theme = "dark",
header = DEFAULT_HEADER,
codeArea = DEFAULT_CODE_AREA,
border = DEFAULT_BORDER,
className,
style,
}: CodeBlockProps) {
const [copied, setCopied] = React.useState(false);
const timerRef = React.useRef<number | null>(null);
React.useEffect(() => {
return () => {
if (timerRef.current !== null && typeof window !== "undefined") {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, []);
const normalizedCode = React.useMemo(() => {
return (code ?? "").replace(/\r\n/g, "\n");
}, [code]);
const lines = React.useMemo(() => {
const arr = normalizedCode.split("\n");
return arr.length === 0 ? [""] : arr;
}, [normalizedCode]);
const maxLineNumberDigits = React.useMemo(() => {
return String(Math.max(1, lines.length)).length;
}, [lines.length]);
const tokensByLine = React.useMemo(() => {
if (language === "plain") return null;
return lines.map(line => tokenizeLine(line, language));
}, [lines, language]);
const copyToClipboard = React.useCallback(async () => {
if (typeof window === "undefined" || typeof document === "undefined") return;
const text = normalizedCode;
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "true");
ta.style.position = "absolute";
ta.style.left = "-9999px";
ta.style.top = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
setCopied(true);
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
setCopied(false);
}, Math.max(200, header.copy?.copiedDurationMs ?? 1200));
} catch {
// ignore clipboard errors silently
}
}, [header.copy?.copiedDurationMs, normalizedCode]);
// ── Active colors ──────────────────────────────────────────────────────────
const activeColors = React.useMemo<ActiveColors>(() => {
if (theme === "light") {
return {
border: "#E5E5E5",
containerBg: "#FFFFFF",
headerBg: "#F5F5F5",
title: "#000000",
copy: "#A6A6A6",
copyAccent: "#000000",
codeBg: "#FFFFFF",
text: "#383A42",
subtleText: "#A0A1A7",
selection: "rgba(0, 153, 255, 0.15)",
isDark: false,
};
}
if (theme === "dark") {
return {
border: "#1A1A1A",
containerBg: "#0D0D0D",
headerBg: "#1A1A1A",
title: "#E5E5E5",
copy: "#888888",
copyAccent: "#E5E5E5",
codeBg: "#0D0D0D",
text: "#D4D4D4",
subtleText: "#555555",
selection: "rgba(255, 255, 255, 0.1)",
isDark: true,
};
}
return {
border: border.color ?? "#1A1A1A",
containerBg: codeArea.background ?? "#0D0D0D",
headerBg: header.background ?? "#1A1A1A",
title: header.titleColor ?? "#E5E5E5",
copy: header.copy?.color ?? "#888888",
copyAccent: header.copy?.accentColor ?? "#E5E5E5",
codeBg: codeArea.background ?? "#0D0D0D",
text: codeArea.textColor ?? "#D4D4D4",
subtleText: codeArea.subtleTextColor ?? "#555555",
selection: codeArea.selectionColor ?? "rgba(255,255,255,0.1)",
isDark: isColorDark(codeArea.background ?? "#0D0D0D"),
};
}, [theme, border, header, codeArea]);
const containerBorder =
(border.width ?? 1) > 0
? `${border.width}px solid ${activeColors.border}`
: "none";
const headerHeight = 44;
// ── Header ─────────────────────────────────────────────────────────────────
const headerEl = header.enabled ? (
<div
style={{
flex: "0 0 auto",
height: headerHeight,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "0 12px 0 14px",
background: activeColors.headerBg,
borderBottom: `1px solid ${activeColors.border}`,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
minWidth: 0,
flex: 1,
}}
>
{header.showDots && (
<div
aria-hidden="true"
style={{ display: "flex", gap: 6, alignItems: "center", flex: "0 0 auto" }}
>
<WindowDot color="#F07178" />
<WindowDot color="#FFCB6B" />
<WindowDot color="#C3E88D" />
</div>
)}
<div
style={{
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
color: activeColors.title,
...header.font,
}}
>
{header.title}
</div>
</div>
{header.copy?.enabled && (
<motion.button
type="button"
onClick={copyToClipboard}
whileTap={{ scale: 0.98 }}
style={{
appearance: "none",
border: "none",
outline: "none",
background: "transparent",
color: copied ? activeColors.copyAccent : activeColors.copy,
cursor: "pointer",
padding: "8px 10px",
margin: 0,
borderRadius: 8,
display: "inline-flex",
alignItems: "center",
gap: 8,
...header.font,
}}
aria-label={copied ? header.copy.copiedLabel : header.copy.label}
>
<span style={{ whiteSpace: "nowrap" }}>
{copied ? header.copy.copiedLabel : header.copy.label}
</span>
<CopyIcon />
</motion.button>
)}
</div>
) : null;
// ── Render ─────────────────────────────────────────────────────────────────
const showLineNumbers = codeArea.showLineNumbers ?? true;
const wrap = codeArea.wrap ?? true;
const padding = codeArea.padding ?? 16;
return (
<div
className={className}
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "hidden",
border: containerBorder,
borderRadius: border.radius,
background: activeColors.containerBg,
display: "flex",
flexDirection: "column",
...style,
}}
>
{headerEl}
<div style={{ position: "relative", flex: "1 1 auto", minHeight: 0 }}>
{/* Code scroll area */}
<div
style={{
height: "100%",
overflowY: "auto",
overflowX: wrap ? "hidden" : "auto",
scrollbarWidth: "none",
msOverflowStyle: "none",
padding,
background: activeColors.codeBg,
}}
>
<style>{`
.code-block-scroll::-webkit-scrollbar { width: 0px; height: 0px; }
.code-block-scroll * { scrollbar-width: none; }
::selection { background: ${activeColors.selection}; }
`}</style>
<pre
aria-label={`Code block (${language})`}
className="code-block-scroll"
style={{
margin: 0,
paddingBottom: "30px",
color: activeColors.text,
background: "transparent",
tabSize: 2,
MozTabSize: 2,
position: "relative",
...codeArea.font,
lineHeight: codeArea.font?.lineHeight ?? "1.5em",
whiteSpace: wrap ? "pre-wrap" : "pre",
overflowWrap: wrap ? "anywhere" : "normal",
wordBreak: wrap ? "break-word" : "normal",
}}
>
<code style={{ display: "block" }}>
{lines.map((line, i) => {
const ln = i + 1;
const tokens = tokensByLine ? tokensByLine[i] : null;
return (
<div
key={i}
style={{ display: "flex", alignItems: "flex-start", gap: 12 }}
>
{showLineNumbers && (
<span
aria-hidden="true"
style={{
flex: "0 0 auto",
width: `${Math.max(2, maxLineNumberDigits) + 1}ch`,
textAlign: "right",
color: activeColors.subtleText,
userSelect: "none",
fontVariantNumeric: "tabular-nums",
}}
>
{ln}
</span>
)}
<span
style={{
flex: "1 1 auto",
minWidth: 0,
maxWidth: "100%",
}}
>
{tokens ? (
<HighlightedLine
tokens={tokens}
baseColor={activeColors.text}
isDark={activeColors.isDark}
/>
) : (
<span style={{ color: activeColors.text }}>
{line.length > 0 ? line : " "}
</span>
)}
</span>
</div>
);
})}
</code>
</pre>
</div>
{/* Bottom fade gradient */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: "50px",
pointerEvents: "none",
background: `linear-gradient(to bottom, transparent, ${setAlpha(activeColors.codeBg, 0.7)})`,
}}
/>
</div>
</div>
);
}
export default CodeBlock;
next-ui/chat-messages/chat-messages.tsx
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Send, RotateCcw, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ChatMessage {
id: string;
sender: "user" | "assistant";
content: string;
timestamp?: string;
}
export interface ChatMessagesProps {
messages?: ChatMessage[];
autoPlay?: boolean;
autoPlayDelay?: number;
typingDuration?: number;
showReplay?: boolean;
interactive?: boolean;
className?: string;
}
const DEFAULT_MESSAGES: ChatMessage[] = [
{ id: "1", sender: "assistant", content: "Hello! I'm your Nexus AI assistant. How can I help you build something amazing today?" },
{ id: "2", sender: "user", content: "I want to create a beautiful landing page for my SaaS product." },
{ id: "3", sender: "assistant", content: "Great choice! Nexus UI has everything you need — animated components, premium styling, and easy integration. What feature would you like to showcase first?" },
{ id: "4", sender: "user", content: "I need something that will impress investors during pitch meetings." },
{ id: "5", sender: "assistant", content: "Perfect. I'd recommend our Hero Aurora Spotlight combined with the Cinematic Logo Cloud. They create a stunning first impression that showcases both innovation and credibility." },
{ id: "6", sender: "user", content: "Can I see how they look together?" },
{ id: "7", sender: "assistant", content: "Absolutely. Click any component in the sidebar to preview it live, then copy the code directly into your project. No complex setup required." },
];
function TypingIndicator({ className }: { className?: string }) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className={cn(
"inline-flex items-center gap-1 rounded-2xl rounded-tl-md border border-white/10 bg-zinc-800/90 px-4 py-3 backdrop-blur-sm",
className,
)}
>
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="h-2 w-2 rounded-full bg-white/60"
animate={{ opacity: [0.4, 1, 0.4], y: [0, -4, 0] }}
transition={{
duration: 0.8,
repeat: Infinity,
delay: i * 0.15,
ease: "easeInOut",
}}
/>
))}
</motion.div>
);
}
function MessageBubble({
message,
isLast,
}: {
message: ChatMessage;
isLast?: boolean;
}) {
const isUser = message.sender === "user";
return (
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.96, x: isUser ? 20 : -20 }}
animate={{ opacity: 1, y: 0, scale: 1, x: 0 }}
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
className={cn(
"flex w-full",
isUser ? "justify-end" : "justify-start",
)}
>
<div className={cn("flex items-end gap-2", isUser && "flex-row-reverse")}>
{!isUser && (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600">
<Sparkles className="size-4 text-white" />
</div>
)}
<motion.div
layout
className={cn(
"max-w-[75%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed",
isUser
? "rounded-tr-md bg-gradient-to-r from-indigo-600 to-violet-600 text-white shadow-[0_8px_24px_-4px_rgba(99,102,241,0.4)]"
: "rounded-tl-md border border-white/10 bg-zinc-800/90 text-zinc-100 backdrop-blur-sm shadow-[0_4px_12px_-2px_rgba(0,0,0,0.3)]",
)}
whileHover={{ scale: 1.01, y: -1 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
{message.content}
</motion.div>
</div>
</motion.div>
);
}
export function ChatMessages({
messages = DEFAULT_MESSAGES,
autoPlay = true,
autoPlayDelay = 1800,
typingDuration = 1400,
showReplay = true,
interactive = false,
className,
}: ChatMessagesProps) {
const [visibleCount, setVisibleCount] = useState(autoPlay ? 0 : messages.length);
const [isTyping, setIsTyping] = useState(false);
const [inputValue, setInputValue] = useState("");
const [chatMessages, setChatMessages] = useState<ChatMessage[]>(messages);
const scrollRef = useRef<HTMLDivElement>(null);
const isAutoPlaying = useRef(false);
const scrollToBottom = useCallback(() => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}
}, []);
const revealNext = useCallback(
async (index: number) => {
if (index >= chatMessages.length) {
isAutoPlaying.current = false;
return;
}
const message = chatMessages[index];
if (message.sender === "assistant") {
setIsTyping(true);
await new Promise((r) => setTimeout(r, typingDuration));
setIsTyping(false);
}
setVisibleCount(index + 1);
await new Promise((r) => setTimeout(r, 100));
scrollToBottom();
await new Promise((r) => setTimeout(r, autoPlayDelay - (message.sender === "assistant" ? typingDuration : 0) - 100));
if (isAutoPlaying.current) {
revealNext(index + 1);
}
},
[chatMessages, autoPlayDelay, typingDuration, scrollToBottom],
);
const startAutoPlay = useCallback(() => {
if (autoPlay && visibleCount === 0) {
isAutoPlaying.current = true;
revealNext(0);
}
}, [autoPlay, visibleCount, revealNext]);
const replay = useCallback(() => {
setVisibleCount(0);
setChatMessages(messages);
isAutoPlaying.current = true;
setTimeout(() => revealNext(0), 100);
}, [messages, revealNext]);
useEffect(() => {
setChatMessages(messages);
if (autoPlay) {
setVisibleCount(0);
isAutoPlaying.current = true;
const timer = setTimeout(() => revealNext(0), 500);
return () => {
clearTimeout(timer);
isAutoPlaying.current = false;
};
} else {
setVisibleCount(messages.length);
}
}, [messages, autoPlay, revealNext]);
useEffect(() => {
scrollToBottom();
}, [visibleCount, isTyping, scrollToBottom]);
const handleSend = useCallback(() => {
if (!inputValue.trim() || !interactive) return;
const newMessage: ChatMessage = {
id: `user-${Date.now()}`,
sender: "user",
content: inputValue.trim(),
};
setChatMessages((prev) => [...prev, newMessage]);
setInputValue("");
setVisibleCount((prev) => prev + 1);
setTimeout(() => {
const assistantReply: ChatMessage = {
id: `assistant-${Date.now()}`,
sender: "assistant",
content: "That's a great question! Let me help you explore the best options for your use case.",
};
setChatMessages((prev) => [...prev, assistantReply]);
setVisibleCount((prev) => prev + 1);
}, typingDuration + 500);
}, [inputValue, interactive, typingDuration]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div
className={cn(
"relative flex flex-col overflow-hidden rounded-2xl border border-white/10 bg-gradient-to-b from-zinc-900 to-zinc-950 shadow-[0_24px_64px_-16px_rgba(0,0,0,0.5)]",
className,
)}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-white/5 px-4 py-3">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600">
<Sparkles className="size-4 text-white" />
</div>
<div>
<h3 className="text-sm font-medium text-white">Nexus AI</h3>
<p className="text-xs text-white/40">Always here to help</p>
</div>
</div>
{showReplay && (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={replay}
aria-label="Replay conversation"
className="flex items-center gap-1.5 rounded-lg bg-white/5 px-3 py-1.5 text-xs text-white/60 transition-colors hover:bg-white/10 hover:text-white"
>
<RotateCcw className="size-3.5" />
Replay
</motion.button>
)}
</div>
{/* Messages */}
<div
ref={scrollRef}
role="log"
aria-label="Chat messages"
aria-live="polite"
className="flex-1 space-y-3 overflow-y-auto p-4 scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent"
>
{chatMessages.slice(0, visibleCount).map((message, i) => (
<MessageBubble
key={message.id}
message={message}
isLast={i === visibleCount - 1}
/>
))}
<AnimatePresence>
{isTyping && <TypingIndicator />}
</AnimatePresence>
</div>
{/* Input */}
<div className="border-t border-white/5 p-3">
<div className="flex items-center gap-2 rounded-xl border border-white/10 bg-zinc-800/50 px-4 py-2 backdrop-blur-sm focus-within:border-white/20 focus-within:bg-zinc-800/70">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
disabled={!interactive}
placeholder={interactive ? "Ask Nexus AI..." : "Demo mode - replay to watch again"}
aria-label={interactive ? "Type your message" : "Chat input (demo mode)"}
className="flex-1 bg-transparent text-sm text-white outline-none placeholder:text-white/30 disabled:cursor-not-allowed"
/>
<motion.button
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.92 }}
onClick={handleSend}
disabled={!interactive || !inputValue.trim()}
aria-label="Send message"
className={cn(
"flex h-8 w-8 items-center justify-center rounded-lg transition-colors",
interactive && inputValue.trim()
? "bg-indigo-600 text-white hover:bg-indigo-500"
: "bg-white/5 text-white/30",
)}
>
<Send className="size-4" />
</motion.button>
</div>
</div>
</div>
);
}
next-ui/chat-messages/index.ts
export { ChatMessages } from "./chat-messages";
export type { ChatMessagesProps, ChatMessage } from "./chat-messages";
next-ui/card-stack-rotator/card-stack-rotator.tsx
"use client";
import * as React from "react";
import { motion, AnimatePresence, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
// ── Types ─────────────────────────────────────────────────────────────────────
export interface CardData {
id: string;
content: string;
name: string;
designation: string;
avatarUrl?: string;
initials?: string;
company?: string;
companyLogoUrl?: string;
}
export interface CardStackRotatorProps {
cards: CardData[];
rotationInterval?: number;
maxVisible?: number;
stackOffset?: number;
scaleStep?: number;
cardWidth?: string;
cardHeight?: string;
cardBackground?: string;
cardBorderColor?: string;
textColor?: string;
mutedColor?: string;
highlightBackground?: string;
highlightTextColor?: string;
contentFontSize?: string;
nameFontSize?: string;
designationFontSize?: string;
borderRadius?: string;
shadowIntensity?: number;
pauseOnHover?: boolean;
direction?: "forward" | "backward";
className?: string;
}
// ── Highlight parser ───────────────────────────────────────────────────────────
function parseHighlights(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
const segments = text.split(/(\*\*[^*]+\*\*)/);
let key = 0;
for (const seg of segments) {
const match = seg.match(/^\*\*([^*]+)\*\*$/);
if (match) {
parts.push(
<mark
key={key++}
style={{
background: "rgba(129,140,248,0.18)",
color: "#a5b4fc",
borderRadius: "4px",
padding: "0 2px",
}}
>
{match[1]}
</mark>,
);
} else if (seg) {
parts.push(seg);
}
}
return parts;
}
// ── Avatar ─────────────────────────────────────────────────────────────────────
interface AvatarProps {
src?: string;
initials?: string;
size?: number;
background?: string;
textColor?: string;
}
function Avatar({ src, initials, size = 40, background, textColor }: AvatarProps) {
if (src) {
return (
<img
src={src}
alt={initials ?? "avatar"}
className="rounded-full object-cover"
style={{ width: size, height: size }}
/>
);
}
return (
<div
className="flex items-center justify-center rounded-full"
style={{
width: size,
height: size,
background: background ?? "rgba(129,140,248,0.2)",
color: textColor ?? "#a5b4fc",
fontSize: size * 0.36,
fontWeight: 600,
}}
>
{initials ?? "?"}
</div>
);
}
// ── Spring config ──────────────────────────────────────────────────────────────
const SPRING_ENTER = { type: "spring" as const, stiffness: 280, damping: 30, mass: 1 };
const SPRING_EXIT = { type: "spring" as const, stiffness: 320, damping: 32, mass: 0.8 };
// ── Main component ─────────────────────────────────────────────────────────────
export function CardStackRotator({
cards,
rotationInterval = 3000,
maxVisible = 4,
stackOffset = 16,
scaleStep = 0.04,
cardWidth = "100%",
cardHeight = "380px",
cardBackground = "#111113",
cardBorderColor = "rgba(255,255,255,0.07)",
textColor = "#fafafa",
mutedColor = "rgba(250,250,250,0.45)",
highlightTextColor = "#a5b4fc",
contentFontSize = "15px",
nameFontSize = "15px",
designationFontSize = "12px",
borderRadius = "16px",
shadowIntensity = 0.55,
pauseOnHover = true,
className,
}: CardStackRotatorProps) {
const reduceMotion = useReducedMotion() === true;
const [frontIndex, setFrontIndex] = React.useState(0);
const [isPaused, setIsPaused] = React.useState(false);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const totalCards = cards.length;
const visibleCount = Math.min(maxVisible, totalCards);
// Visible window: front card at position 0, subsequent cards behind
const visibleCards = React.useMemo(() => {
if (!totalCards) return [];
const front = frontIndex % totalCards;
return Array.from({ length: visibleCount }, (_, i) => {
const cardIdx = (front + i) % totalCards;
return { card: cards[cardIdx], stackIndex: i };
});
}, [cards, frontIndex, visibleCount, totalCards]);
React.useEffect(() => {
if (isPaused || reduceMotion) {
if (timerRef.current) clearTimeout(timerRef.current);
return;
}
const id = setTimeout(() => {
setFrontIndex((prev) => (prev + 1) % totalCards);
}, rotationInterval);
timerRef.current = id;
return () => clearTimeout(id);
}, [isPaused, reduceMotion, frontIndex, rotationInterval, totalCards]);
if (!totalCards) return null;
const shadowBlur = 20 + shadowIntensity * 30;
const shadowOpacity = 0.12 + shadowIntensity * 0.28;
return (
<div
className={cn("relative flex items-center justify-center", className)}
style={{ width: cardWidth, height: cardHeight }}
onMouseEnter={() => pauseOnHover && setIsPaused(true)}
onMouseLeave={() => pauseOnHover && setIsPaused(false)}
>
<AnimatePresence mode="popLayout">
{visibleCards.map(({ card, stackIndex }) => {
const yOffset = stackIndex * stackOffset;
const scale = 1 - stackIndex * scaleStep;
const zBase = visibleCount - stackIndex;
return (
<motion.div
key={card.id}
layout
// Initial state for cards entering the stack
initial={{
y: yOffset + 56,
scale: scale - 0.07,
opacity: 0,
zIndex: zBase,
}}
// Animate to final stacked position
animate={{
y: yOffset,
scale,
opacity: 1,
zIndex: zBase,
transition: {
...SPRING_ENTER,
delay: stackIndex * 0.04, // slight cascade for depth feel
},
}}
// Exit animation — front card exits upward
exit={{
y: yOffset - 75,
scale: 1.05,
opacity: 0,
zIndex: 0,
transition: {
...SPRING_EXIT,
},
}}
style={{
position: "absolute",
top: 0,
left: 0,
width: cardWidth,
height: cardHeight,
transformOrigin: "center bottom",
willChange: "transform, opacity",
}}
>
<div
className="flex h-full flex-col justify-between rounded-2xl p-6"
style={{
background: cardBackground,
border: `1px solid ${cardBorderColor}`,
borderRadius,
boxShadow: `0 ${shadowBlur}px ${shadowBlur * 1.5}px rgba(0,0,0,${shadowOpacity}), 0 2px 8px rgba(0,0,0,${shadowOpacity * 0.5})`,
color: textColor,
backfaceVisibility: "hidden",
}}
>
{/* Content */}
<div
style={{
fontSize: contentFontSize,
color: textColor,
lineHeight: 1.65,
}}
>
{parseHighlights(card.content)}
</div>
{/* Footer */}
<div className="mt-5 flex items-center gap-3">
<Avatar
src={card.avatarUrl}
initials={
card.initials ??
card.name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase()
}
size={40}
background="rgba(129,140,248,0.15)"
textColor={highlightTextColor}
/>
<div>
<p
style={{
fontSize: nameFontSize,
color: textColor,
fontWeight: 600,
}}
>
{card.name}
</p>
<p
style={{
fontSize: designationFontSize,
color: mutedColor,
marginTop: 2,
}}
>
{card.designation}
{card.company ? ` · ${card.company}` : ""}
</p>
</div>
</div>
</div>
</motion.div>
);
})}
</AnimatePresence>
</div>
);
}
next-ui/encrypted-text/encrypted-text.tsx
"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
const DEFAULT_CHARSET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*!<>/|[]{}~=-_+:.";
function pickRandom(chars: string): string {
return chars[Math.floor(Math.random() * chars.length)] ?? chars[0]!;
}
function isPreserved(c: string): boolean {
return c === " " || c === "\n" || c === "\t";
}
type CharState = { char: string; locked: boolean };
function initStates(text: string, charset: string): CharState[] {
return Array.from(text).map((c) =>
isPreserved(c) ? { char: c, locked: true } : { char: pickRandom(charset), locked: false },
);
}
export type EncryptedTextTrigger = "load" | "hover" | "viewport" | "click";
export interface EncryptedTextProps {
text: string;
className?: string;
/** ms between each character locking in. Default: 50 */
speed?: number;
/** ms between gibberish shuffle ticks. Default: 35 */
shuffleSpeed?: number;
/** ms delay before animation starts. Default: 0 */
revealDelay?: number;
/** Charset for the gibberish phase. */
characters?: string;
/** Shuffle cycles before first character locks in. Default: 8 */
iterations?: number;
/** What triggers the reveal. Default: "load" */
trigger?: EncryptedTextTrigger;
/** Loop the animation after completion. Default: false */
loop?: boolean;
/** ms to wait before replaying when loop=true. Default: 2000 */
loopDelay?: number;
/** Polymorphic element. Default: "span" */
as?: "span" | "p" | "h1" | "h2" | "h3" | "div";
/** Called once every character has locked in. */
onComplete?: () => void;
/**ClassName applied to scrambling (unlocked) characters. Defaults to primary/cyan glow. */
scrambleClassName?: string;
}
export function EncryptedText({
text,
className,
speed = 50,
shuffleSpeed = 35,
revealDelay = 0,
characters = DEFAULT_CHARSET,
iterations = 8,
trigger = "load",
loop = false,
loopDelay = 2000,
as: Tag = "span",
onComplete,
scrambleClassName,
}: EncryptedTextProps) {
const reduceMotion = useReducedMotion();
const rootRef = React.useRef<HTMLElement>(null);
const isInView = useInView(rootRef as React.RefObject<Element>, {
once: !loop,
amount: 0.3,
});
const [states, setStates] = React.useState<CharState[]>(() =>
Array.from(text).map((c) => ({ char: c, locked: true }))
);
// Always-current config refs — read inside effects without stale closure risk
const cfg = React.useRef({ text, characters, speed, shuffleSpeed, iterations, loop, loopDelay, onComplete });
cfg.current = { text, characters, speed, shuffleSpeed, iterations, loop, loopDelay, onComplete };
// Incrementing this triggers one animation run
const [cycle, setCycle] = React.useState(0);
// play() is the single entry point for all trigger modes
const play = React.useCallback(() => setCycle((n) => n + 1), []);
// Core animation — isolated per cycle so cleanup fully cancels the previous run
React.useEffect(() => {
if (cycle === 0) return;
const { text: t, characters: ch, speed: sp, shuffleSpeed: ss,
iterations: it, loop: lp, loopDelay: ld, onComplete: cb } = cfg.current;
if (reduceMotion) {
setStates(Array.from(t).map((c) => ({ char: c, locked: true })));
cb?.();
return;
}
let dead = false;
let revealIdx = 0;
setStates(initStates(t, ch));
// Phase 1 — fast shuffle of all unrevealed characters
const shuffleTimer = setInterval(() => {
if (dead) return;
setStates((prev) =>
prev.map((s) => (s.locked ? s : { char: pickRandom(ch), locked: false })),
);
}, ss);
// Phase 2 — after warmup, lock one character per tick
let revealTimer: ReturnType<typeof setInterval> | null = null;
let loopTimer: ReturnType<typeof setTimeout> | null = null;
const warmupTimer = setTimeout(() => {
if (dead) return;
revealTimer = setInterval(() => {
if (dead) return;
if (revealIdx >= t.length) {
clearInterval(revealTimer!);
clearInterval(shuffleTimer);
if (!dead) {
setStates(Array.from(t).map((c) => ({ char: c, locked: true })));
cb?.();
if (lp) {
loopTimer = setTimeout(() => { if (!dead) setCycle((n) => n + 1); }, ld);
}
}
return;
}
const idx = revealIdx++;
setStates((prev) => {
const next = [...prev];
next[idx] = { char: t[idx]!, locked: true };
return next;
});
}, sp);
}, it * ss);
return () => {
dead = true;
clearInterval(shuffleTimer);
clearTimeout(warmupTimer);
if (revealTimer) clearInterval(revealTimer);
if (loopTimer) clearTimeout(loopTimer);
};
}, [cycle, reduceMotion]);
// Trigger: load — schedule play() after revealDelay
React.useEffect(() => {
if (trigger !== "load") return;
const t = setTimeout(play, revealDelay);
return () => clearTimeout(t);
}, [trigger, revealDelay, play]);
// Trigger: viewport — play() once the element enters the viewport
React.useEffect(() => {
if (trigger !== "viewport" || !isInView) return;
const t = setTimeout(play, revealDelay);
return () => clearTimeout(t);
}, [trigger, isInView, revealDelay, play]);
// Trigger: hover / click
const handleInteraction = React.useCallback(() => {
setTimeout(play, revealDelay);
}, [play, revealDelay]);
const interactionProps: React.HTMLAttributes<HTMLElement> = {};
if (trigger === "hover") {
interactionProps.onMouseEnter = handleInteraction;
} else if (trigger === "click") {
interactionProps.onClick = handleInteraction;
interactionProps.onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") handleInteraction();
};
}
return (
<Tag
ref={rootRef as React.RefObject<never>}
aria-label={text}
role={trigger === "click" ? "button" : undefined}
tabIndex={trigger === "click" ? 0 : undefined}
className={cn(
"font-mono whitespace-pre-wrap",
trigger === "click" && "cursor-pointer select-none",
className,
)}
{...interactionProps}
>
{states.map((s, i) => (
<span
key={i}
aria-hidden="true"
className={cn(
"inline-block transition-colors duration-75",
s.locked ? "text-current" : (scrambleClassName ?? "text-primary dark:text-cyan-400"),
)}
style={
!s.locked
? { filter: "drop-shadow(0 0 5px currentColor)", opacity: 0.85 }
: undefined
}
>
{s.char}
</span>
))}
</Tag>
);
}
next-ui/encrypted-text/index.ts
export type { EncryptedTextProps, EncryptedTextTrigger } from "./encrypted-text";
export { EncryptedText } from "./encrypted-text";
next-ui/typewriter-effect/index.ts
export type { TypewriterEffectProps } from "./typewriter-effect";
export { TypewriterEffect } from "./typewriter-effect";
next-ui/typewriter-effect/typewriter-effect.tsx
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
type TypewriterTag = "h1" | "h2" | "p" | "span" | "div";
type TypewriterText = string | string[];
type TypewriterPhase = "typing" | "pausing" | "deleting" | "done";
export type TypewriterEffectProps = {
text: TypewriterText;
typingSpeed?: number;
deletingSpeed?: number;
delay?: number;
pauseDuration?: number;
loop?: boolean;
showCursor?: boolean;
cursorClassName?: string;
className?: string;
as?: TypewriterTag;
onComplete?: () => void;
};
const clamp = (value: number, min: number) => Math.max(min, value);
export function TypewriterEffect({
text,
typingSpeed = 65,
deletingSpeed = 42,
delay = 0,
pauseDuration = 1200,
loop = false,
showCursor = true,
cursorClassName,
className,
as = "span",
onComplete,
}: TypewriterEffectProps) {
const reduceMotion = useReducedMotion() === true;
const Tag = as;
const textList = React.useMemo(() => {
const list = Array.isArray(text) ? text : [text];
const normalized = list.map((item) => item ?? "").filter((item) => item.length > 0);
return normalized.length ? normalized : [""];
}, [text]);
const [phase, setPhase] = React.useState<TypewriterPhase>(reduceMotion ? "done" : "typing");
const [textIndex, setTextIndex] = React.useState(0);
const [charIndex, setCharIndex] = React.useState(reduceMotion ? textList[0].length : 0);
const completionFiredRef = React.useRef(false);
const currentText = textList[textIndex] ?? "";
const visibleText = currentText.slice(0, charIndex);
const hasMultipleTexts = textList.length > 1;
const canDelete = deletingSpeed > 0;
const shouldLoop = loop === true;
const longestChars = React.useMemo(
() => textList.reduce((max, item) => Math.max(max, item.length), 0),
[textList],
);
React.useEffect(() => {
completionFiredRef.current = false;
if (reduceMotion) {
setTextIndex(Math.max(0, textList.length - 1));
setCharIndex(textList[Math.max(0, textList.length - 1)]?.length ?? 0);
setPhase("done");
onComplete?.();
return;
}
setTextIndex(0);
setCharIndex(0);
setPhase("typing");
}, [reduceMotion, textList, onComplete]);
React.useEffect(() => {
if (reduceMotion || phase === "done") return;
const typeMs = clamp(typingSpeed, 10);
const deleteMs = clamp(deletingSpeed, 10);
const waitMs = clamp(pauseDuration, 0);
const initialDelay = clamp(delay, 0);
const timer = window.setTimeout(() => {
if (phase === "typing") {
if (charIndex < currentText.length) {
setCharIndex((v) => v + 1);
return;
}
setPhase("pausing");
return;
}
if (phase === "pausing") {
const isLastText = textIndex === textList.length - 1;
const hasNextText = !isLastText || shouldLoop;
if (!hasNextText) {
if (!completionFiredRef.current) {
completionFiredRef.current = true;
onComplete?.();
}
setPhase("done");
return;
}
if (!canDelete) {
const nextIndex = isLastText ? 0 : textIndex + 1;
setTextIndex(nextIndex);
setCharIndex(0);
if (isLastText && shouldLoop) onComplete?.();
setPhase("typing");
return;
}
setPhase("deleting");
return;
}
if (phase === "deleting") {
if (charIndex > 0) {
setCharIndex((v) => v - 1);
return;
}
const isLastText = textIndex === textList.length - 1;
const nextIndex = isLastText ? 0 : textIndex + 1;
if (isLastText) onComplete?.();
setTextIndex(nextIndex);
setPhase("typing");
}
}, phase === "typing" ? (charIndex === 0 ? initialDelay : typeMs) : phase === "pausing" ? waitMs : deleteMs);
return () => window.clearTimeout(timer);
}, [
canDelete,
charIndex,
currentText.length,
delay,
deletingSpeed,
onComplete,
pauseDuration,
phase,
reduceMotion,
shouldLoop,
textIndex,
textList.length,
typingSpeed,
]);
return (
<Tag className={cn("relative inline-flex items-baseline whitespace-pre-wrap", className)}>
<span className="sr-only">{Array.isArray(text) ? text.join(" ") : text}</span>
<span
aria-hidden
className="inline-block whitespace-pre-wrap"
style={{ minWidth: hasMultipleTexts ? `${Math.max(1, longestChars)}ch` : undefined }}
>
{visibleText}
</span>
{showCursor ? (
<motion.span
aria-hidden
className={cn("ml-1 inline-block h-[1em] w-[2px] rounded-sm bg-current align-[-0.08em]", cursorClassName)}
animate={{ opacity: [1, 0.2, 1] }}
transition={{ duration: 0.9, repeat: Infinity, ease: "easeInOut" }}
/>
) : null}
</Tag>
);
}