Web InnoventixFreeCode

Comment Skeleton

Original · free

comment thread skeleton

byWeb InnoventixReact + Tailwind
skelcommentskeletons
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/skel-comment.json
skel-comment.tsx
"use client";

import {
  useEffect,
  useRef,
  useState,
  type KeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Tone = "indigo" | "violet" | "emerald" | "rose" | "amber" | "sky";

type Reply = {
  id: string;
  name: string;
  handle: string;
  time: string;
  body: string;
  likes: number;
  tone: Tone;
};

type Comment = Reply & {
  author?: boolean;
  replies: Reply[];
};

const MODES = ["skeleton", "loaded"] as const;
type Mode = (typeof MODES)[number];

const MIN = 2;
const MAX = 4;

const toneAvatar: Record<Tone, string> = {
  indigo: "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300",
  violet: "bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-300",
  emerald: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300",
  rose: "bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-300",
  amber: "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300",
  sky: "bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300",
};

const THREAD: Comment[] = [
  {
    id: "c1",
    name: "Maya Okonkwo",
    handle: "@mayabuilds",
    time: "2h",
    tone: "indigo",
    likes: 24,
    body: "We shipped almost this exact setup last quarter. The move that actually saved us was versioning tokens separately from components — designers could iterate on color and spacing without waiting on a component release.",
    replies: [
      {
        id: "c1r1",
        name: "Devon Park",
        handle: "@devonp",
        time: "1h",
        tone: "emerald",
        likes: 6,
        body: "This. Decoupling the token pipeline cut our release friction roughly in half.",
      },
    ],
  },
  {
    id: "c2",
    name: "Priya Nair",
    handle: "@priyawrites",
    time: "3h",
    tone: "violet",
    author: true,
    likes: 17,
    body: "Great question on dark mode — we stopped using one gray ramp and split it into a text ramp and a surface ramp. Most of our AA contrast failures on secondary text disappeared overnight.",
    replies: [
      {
        id: "c2r1",
        name: "Ilya Sorokin",
        handle: "@ilya_s",
        time: "2h",
        tone: "amber",
        likes: 4,
        body: "Trying this today. Passing AA on muted body text has been our white whale for a year.",
      },
    ],
  },
  {
    id: "c3",
    name: "Ben Carter",
    handle: "@bcarter",
    time: "6h",
    tone: "rose",
    likes: 8,
    body: "Bookmarking this. The section on documenting motion tokens is the part every team skips until an animation ships that nobody can reproduce.",
    replies: [],
  },
  {
    id: "c4",
    name: "Sofia Reyes",
    handle: "@sofiacodes",
    time: "9h",
    tone: "sky",
    likes: 5,
    body: "Would love a follow-up on migrating legacy components without freezing feature work for a whole sprint. That trade-off is where every rollout I've seen stalls.",
    replies: [],
  },
];

function initials(name: string): string {
  return name
    .split(" ")
    .map((w) => w[0])
    .slice(0, 2)
    .join("")
    .toUpperCase();
}

function SkelBar({ className = "" }: { className?: string }) {
  return (
    <span
      className={
        "relative block overflow-hidden rounded-md bg-slate-200 dark:bg-slate-700 " +
        className
      }
    >
      <span className="skelcm-shine" />
    </span>
  );
}

function SkeletonRow({ reply = false }: { reply?: boolean }) {
  return (
    <div className="flex gap-3">
      <SkelBar
        className={
          reply ? "h-8 w-8 shrink-0 rounded-full" : "h-10 w-10 shrink-0 rounded-full"
        }
      />
      <div className="min-w-0 flex-1 space-y-2.5 pt-1">
        <div className="flex items-center gap-2">
          <SkelBar className={reply ? "h-3 w-20" : "h-3.5 w-28"} />
          <SkelBar className="h-3 w-14" />
        </div>
        <div className="space-y-2">
          <SkelBar className="h-2.5 w-full" />
          <SkelBar className={reply ? "h-2.5 w-3/5" : "h-2.5 w-11/12"} />
          {!reply && <SkelBar className="h-2.5 w-2/3" />}
        </div>
        <div className="flex items-center gap-4 pt-0.5">
          <SkelBar className="h-3 w-10" />
          <SkelBar className="h-3 w-12" />
        </div>
      </div>
    </div>
  );
}

function HeartIcon({ filled }: { filled: boolean }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill={filled ? "currentColor" : "none"}
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4"
      aria-hidden="true"
    >
      <path d="M12 20.5 4.55 13.05a4.5 4.5 0 0 1 6.36-6.36l1.09 1.09 1.09-1.09a4.5 4.5 0 1 1 6.36 6.36L12 20.5Z" />
    </svg>
  );
}

function ReplyIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4"
      aria-hidden="true"
    >
      <path d="M9 14 4 9l5-5" />
      <path d="M4 9h9a7 7 0 0 1 7 7v3" />
    </svg>
  );
}

export default function SkelComment() {
  const reduce = useReducedMotion();

  const [mode, setMode] = useState<Mode>("skeleton");
  const [count, setCount] = useState(3);
  const [showReplies, setShowReplies] = useState(true);
  const [reloadKey, setReloadKey] = useState(0);

  const [liked, setLiked] = useState<Record<string, boolean>>({});
  const [openReply, setOpenReply] = useState<string | null>(null);
  const [draft, setDraft] = useState("");
  const [extraReplies, setExtraReplies] = useState<Record<string, string[]>>({});

  const timerRef = useRef<number | null>(null);
  const segRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);

  const visible = THREAD.slice(0, count);

  const clearTimer = () => {
    if (timerRef.current !== null) {
      window.clearTimeout(timerRef.current);
      timerRef.current = null;
    }
  };

  useEffect(() => clearTimer, []);

  useEffect(() => {
    if (openReply) textareaRef.current?.focus();
  }, [openReply]);

  const selectMode = (m: Mode) => {
    clearTimer();
    setMode(m);
  };

  const simulateLoad = () => {
    clearTimer();
    setReloadKey((k) => k + 1);
    setMode("skeleton");
    timerRef.current = window.setTimeout(() => {
      setMode("loaded");
      timerRef.current = null;
    }, 1600);
  };

  const onSegKeyDown = (e: KeyboardEvent<HTMLButtonElement>, idx: number) => {
    if (e.key === "ArrowRight" || e.key === "ArrowDown") {
      e.preventDefault();
      const n = (idx + 1) % MODES.length;
      selectMode(MODES[n]);
      segRefs.current[n]?.focus();
    } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
      e.preventDefault();
      const n = (idx - 1 + MODES.length) % MODES.length;
      selectMode(MODES[n]);
      segRefs.current[n]?.focus();
    }
  };

  const postReply = (id: string) => {
    const t = draft.trim();
    if (!t) return;
    setExtraReplies((p) => ({ ...p, [id]: [...(p[id] ?? []), t] }));
    setDraft("");
    setOpenReply(null);
  };

  const ringBtn =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";

  const transition = { duration: reduce ? 0 : 0.28, ease: "easeOut" as const };

  return (
    <section className="relative w-full bg-slate-50 py-16 dark:bg-slate-950 sm:py-20">
      <style>{`
        @keyframes skelcm-sweep {
          0% { transform: translateX(-100%); }
          100% { transform: translateX(100%); }
        }
        .skelcm-shine {
          position: absolute;
          inset: 0;
          transform: translateX(-100%);
          background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.5) 50%, transparent 100%);
          animation: skelcm-sweep 1.6s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .skelcm-shine { animation: none; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-2xl px-4 sm:px-6">
        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          {/* Header */}
          <div className="border-b border-slate-200 px-5 py-4 dark:border-slate-800 sm:px-6">
            <div className="flex items-center gap-2.5">
              <span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300">
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={1.8}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  className="h-5 w-5"
                  aria-hidden="true"
                >
                  <path d="M21 11.5a8.38 8.38 0 0 1-8.5 8.5 9 9 0 0 1-4-.9L3 21l1.9-5.5a8.38 8.38 0 0 1-.9-4A8.5 8.5 0 0 1 12.5 3 8.38 8.38 0 0 1 21 11.5Z" />
                </svg>
              </span>
              <div className="min-w-0">
                <h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
                  Discussion
                  <span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-400">
                    {visible.length}
                  </span>
                </h2>
                <p className="truncate text-sm text-slate-500 dark:text-slate-400">
                  A live preview of the loading state vs. the resolved thread.
                </p>
              </div>
            </div>

            {/* Toolbar */}
            <div className="mt-4 flex flex-wrap items-center gap-3">
              {/* Segmented mode control */}
              <div
                role="radiogroup"
                aria-label="Thread state"
                className="inline-flex rounded-lg border border-slate-200 bg-slate-100 p-0.5 dark:border-slate-700 dark:bg-slate-800"
              >
                {MODES.map((m, i) => {
                  const active = mode === m;
                  return (
                    <button
                      key={m}
                      ref={(el) => {
                        segRefs.current[i] = el;
                      }}
                      type="button"
                      role="radio"
                      aria-checked={active}
                      tabIndex={active ? 0 : -1}
                      onKeyDown={(e) => onSegKeyDown(e, i)}
                      onClick={() => selectMode(m)}
                      className={
                        "rounded-[7px] px-3 py-1.5 text-sm font-medium capitalize transition motion-reduce:transition-none " +
                        ringBtn +
                        (active
                          ? " bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-slate-100"
                          : " text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200")
                      }
                    >
                      {m}
                    </button>
                  );
                })}
              </div>

              {/* Count stepper */}
              <div
                className="inline-flex items-center gap-1 rounded-lg border border-slate-200 bg-white p-0.5 dark:border-slate-700 dark:bg-slate-900"
                role="group"
                aria-label="Number of comments"
              >
                <button
                  type="button"
                  aria-label="Fewer comments"
                  disabled={count <= MIN}
                  onClick={() => setCount((c) => Math.max(MIN, c - 1))}
                  className={
                    "inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-600 transition hover:bg-slate-100 disabled:pointer-events-none disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800 motion-reduce:transition-none " +
                    ringBtn
                  }
                >
                  <svg
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={2}
                    strokeLinecap="round"
                    className="h-4 w-4"
                    aria-hidden="true"
                  >
                    <path d="M5 12h14" />
                  </svg>
                </button>
                <span
                  aria-live="polite"
                  aria-label={`${count} comments`}
                  className="w-5 text-center text-sm font-semibold tabular-nums text-slate-800 dark:text-slate-100"
                >
                  {count}
                </span>
                <button
                  type="button"
                  aria-label="More comments"
                  disabled={count >= MAX}
                  onClick={() => setCount((c) => Math.min(MAX, c + 1))}
                  className={
                    "inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-600 transition hover:bg-slate-100 disabled:pointer-events-none disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800 motion-reduce:transition-none " +
                    ringBtn
                  }
                >
                  <svg
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={2}
                    strokeLinecap="round"
                    className="h-4 w-4"
                    aria-hidden="true"
                  >
                    <path d="M12 5v14M5 12h14" />
                  </svg>
                </button>
              </div>

              {/* Replies switch */}
              <div className="inline-flex items-center gap-2">
                <span
                  id="skelcm-replies-label"
                  className="text-sm font-medium text-slate-600 dark:text-slate-300"
                >
                  Replies
                </span>
                <button
                  type="button"
                  role="switch"
                  aria-checked={showReplies}
                  aria-labelledby="skelcm-replies-label"
                  onClick={() => setShowReplies((v) => !v)}
                  className={
                    "relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors motion-reduce:transition-none " +
                    ringBtn +
                    (showReplies
                      ? " bg-indigo-600 dark:bg-indigo-500"
                      : " bg-slate-300 dark:bg-slate-700")
                  }
                >
                  <span
                    className={
                      "inline-block h-[18px] w-[18px] transform rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none " +
                      (showReplies ? "translate-x-6" : "translate-x-1")
                    }
                  />
                </button>
              </div>

              {/* Reload */}
              <button
                type="button"
                onClick={simulateLoad}
                className={
                  "ml-auto inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 motion-reduce:transition-none " +
                  ringBtn
                }
              >
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={1.8}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  className="h-4 w-4"
                  aria-hidden="true"
                >
                  <path d="M21 12a9 9 0 1 1-2.64-6.36" />
                  <path d="M21 3v6h-6" />
                </svg>
                Reload
              </button>
            </div>
          </div>

          {/* Live status for assistive tech */}
          <p className="sr-only" role="status" aria-live="polite">
            {mode === "skeleton"
              ? "Loading comments"
              : `${visible.length} comments loaded`}
          </p>

          {/* Thread body */}
          <div
            aria-busy={mode === "skeleton"}
            className="px-5 py-5 sm:px-6 sm:py-6"
          >
            <AnimatePresence mode="wait" initial={false}>
              {mode === "skeleton" ? (
                <motion.div
                  key={`skeleton-${reloadKey}`}
                  aria-hidden="true"
                  initial={{ opacity: 0, y: reduce ? 0 : 6 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: reduce ? 0 : -6 }}
                  transition={transition}
                  className="space-y-7"
                >
                  {Array.from({ length: count }).map((_, i) => (
                    <div key={i} className="space-y-4">
                      <SkeletonRow />
                      {showReplies && i % 2 === 0 && (
                        <div className="ml-5 border-l border-slate-200 pl-4 dark:border-slate-800">
                          <SkeletonRow reply />
                        </div>
                      )}
                    </div>
                  ))}
                </motion.div>
              ) : (
                <motion.ul
                  key="loaded"
                  initial={{ opacity: 0, y: reduce ? 0 : 6 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: reduce ? 0 : -6 }}
                  transition={transition}
                  className="space-y-7"
                >
                  {visible.map((c) => {
                    const isLiked = liked[c.id] ?? false;
                    const extra = extraReplies[c.id] ?? [];
                    return (
                      <li key={c.id} className="space-y-4">
                        <article className="flex gap-3">
                          <span
                            className={
                              "inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-semibold " +
                              toneAvatar[c.tone]
                            }
                            aria-hidden="true"
                          >
                            {initials(c.name)}
                          </span>
                          <div className="min-w-0 flex-1">
                            <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
                              <span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
                                {c.name}
                              </span>
                              {c.author && (
                                <span className="rounded-full bg-indigo-100 px-1.5 py-0.5 text-[11px] font-medium text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">
                                  Author
                                </span>
                              )}
                              <span className="text-xs text-slate-400 dark:text-slate-500">
                                {c.handle} · {c.time}
                              </span>
                            </div>
                            <p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
                              {c.body}
                            </p>
                            <div className="mt-2 flex items-center gap-4">
                              <button
                                type="button"
                                aria-pressed={isLiked}
                                aria-label={`Like comment by ${c.name}`}
                                onClick={() =>
                                  setLiked((p) => ({ ...p, [c.id]: !isLiked }))
                                }
                                className={
                                  "inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs font-medium transition active:scale-95 motion-reduce:transition-none motion-reduce:active:scale-100 " +
                                  ringBtn +
                                  (isLiked
                                    ? " text-rose-600 dark:text-rose-400"
                                    : " text-slate-500 hover:text-rose-600 dark:text-slate-400 dark:hover:text-rose-400")
                                }
                              >
                                <HeartIcon filled={isLiked} />
                                <span className="tabular-nums">
                                  {c.likes + (isLiked ? 1 : 0)}
                                </span>
                              </button>
                              <button
                                type="button"
                                aria-expanded={openReply === c.id}
                                aria-label={`Reply to ${c.name}`}
                                onClick={() => {
                                  setOpenReply((o) => (o === c.id ? null : c.id));
                                  setDraft("");
                                }}
                                className={
                                  "inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs font-medium text-slate-500 transition hover:text-indigo-600 dark:text-slate-400 dark:hover:text-indigo-400 motion-reduce:transition-none " +
                                  ringBtn
                                }
                              >
                                <ReplyIcon />
                                Reply
                              </button>
                            </div>

                            {openReply === c.id && (
                              <div className="mt-3">
                                <label htmlFor={`skelcm-draft-${c.id}`} className="sr-only">
                                  Write a reply to {c.name}
                                </label>
                                <textarea
                                  id={`skelcm-draft-${c.id}`}
                                  ref={textareaRef}
                                  value={draft}
                                  onChange={(e) => setDraft(e.target.value)}
                                  rows={2}
                                  placeholder={`Reply to ${c.name.split(" ")[0]}…`}
                                  className={
                                    "w-full resize-none rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500 " +
                                    ringBtn
                                  }
                                />
                                <div className="mt-2 flex items-center justify-end gap-2">
                                  <button
                                    type="button"
                                    onClick={() => {
                                      setOpenReply(null);
                                      setDraft("");
                                    }}
                                    className={
                                      "rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800 motion-reduce:transition-none " +
                                      ringBtn
                                    }
                                  >
                                    Cancel
                                  </button>
                                  <button
                                    type="button"
                                    disabled={draft.trim().length === 0}
                                    onClick={() => postReply(c.id)}
                                    className={
                                      "rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-indigo-500 disabled:pointer-events-none disabled:opacity-40 dark:bg-indigo-500 dark:hover:bg-indigo-400 motion-reduce:transition-none " +
                                      ringBtn
                                    }
                                  >
                                    Post reply
                                  </button>
                                </div>
                              </div>
                            )}

                            {/* Nested replies */}
                            {showReplies && (c.replies.length > 0 || extra.length > 0) && (
                              <div className="mt-4 space-y-4 border-l border-slate-200 pl-4 dark:border-slate-800">
                                {c.replies.map((r) => {
                                  const rLiked = liked[r.id] ?? false;
                                  return (
                                    <article key={r.id} className="flex gap-3">
                                      <span
                                        className={
                                          "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold " +
                                          toneAvatar[r.tone]
                                        }
                                        aria-hidden="true"
                                      >
                                        {initials(r.name)}
                                      </span>
                                      <div className="min-w-0 flex-1">
                                        <div className="flex flex-wrap items-center gap-x-2">
                                          <span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
                                            {r.name}
                                          </span>
                                          <span className="text-xs text-slate-400 dark:text-slate-500">
                                            {r.handle} · {r.time}
                                          </span>
                                        </div>
                                        <p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
                                          {r.body}
                                        </p>
                                        <button
                                          type="button"
                                          aria-pressed={rLiked}
                                          aria-label={`Like reply by ${r.name}`}
                                          onClick={() =>
                                            setLiked((p) => ({ ...p, [r.id]: !rLiked }))
                                          }
                                          className={
                                            "mt-1.5 inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs font-medium transition active:scale-95 motion-reduce:transition-none motion-reduce:active:scale-100 " +
                                            ringBtn +
                                            (rLiked
                                              ? " text-rose-600 dark:text-rose-400"
                                              : " text-slate-500 hover:text-rose-600 dark:text-slate-400 dark:hover:text-rose-400")
                                          }
                                        >
                                          <HeartIcon filled={rLiked} />
                                          <span className="tabular-nums">
                                            {r.likes + (rLiked ? 1 : 0)}
                                          </span>
                                        </button>
                                      </div>
                                    </article>
                                  );
                                })}

                                {extra.map((text, i) => (
                                  <article key={`x-${i}`} className="flex gap-3">
                                    <span
                                      className={
                                        "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold " +
                                        toneAvatar.sky
                                      }
                                      aria-hidden="true"
                                    >
                                      YOU
                                    </span>
                                    <div className="min-w-0 flex-1">
                                      <div className="flex flex-wrap items-center gap-x-2">
                                        <span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
                                          You
                                        </span>
                                        <span className="text-xs text-slate-400 dark:text-slate-500">
                                          just now
                                        </span>
                                      </div>
                                      <p className="mt-1 whitespace-pre-wrap break-words text-sm leading-relaxed text-slate-600 dark:text-slate-300">
                                        {text}
                                      </p>
                                    </div>
                                  </article>
                                ))}
                              </div>
                            )}
                          </div>
                        </article>
                      </li>
                    );
                  })}
                </motion.ul>
              )}
            </AnimatePresence>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →