shadimage
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 · medium
animationframer-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>
  );
}

More from nexus-labs

Browse all components in this library →