# Gloss — Animated App UI Block for React

> Threaded comments block for a doc, issue or PR page: an auto-growing composer with bold / code / link toolbar, caret-anchored @mention autocomplete painted as tokens right in the textarea and ⌘/Ctrl+Enter to send; optimistic posting that dims with a spinner, flashes the accent on success and offers Retry / Discard on failure; six-emoji reactions whose chips spring in and roll their counts on Tally drums (yours accent-outlined, hover lists who reacted); one-level replies that collapse to "Show N more replies"; resolvable threads that fold into a receipt line; inline edit, confirm-to-delete and copy-link actions on a roving toolbar; an "N new replies" pill for comments that arrive via ref.receive() while you're scrolled away; Oldest/Newest sort and a polite live region.

- Page: https://crucibleui.com/components/app/gloss
- Category: [app](https://crucibleui.com/components/app)
- Tier: pro (one-time purchase, see https://crucibleui.com/pricing)
- Engine: motion
- npm dependencies: motion

## Install

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

Pro install requires adding the token-bearing `@crucible` registry to components.json (issued at https://crucibleui.com/account after purchase).

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

## Props (`GlossProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `comments?` | `GlossComment[]` | `a seeded sample discussion` | Initial comment tree (the thread owns edits/reactions/posts afterwards and reports them through the callbacks). Passing a new array resets it. |
| `people?` | `GlossPerson[]` | `a seeded sample team` | Everyone who can appear in the thread and in `@` autocomplete. |
| `currentUser?` | `GlossPerson` | `the first sample person` | Who "you" are — your comments get Edit / Delete and your reactions are accent-outlined. |
| `onPost?` | `(draft: GlossDraft) => void \| Promise<Partial<GlossComment> \| void>` | — | New comment or reply. The comment appears immediately (optimistic, dimmed with a spinner). Resolve to confirm — optionally with server fields such as `{ id, createdAt }` — or reject to show "Failed to send · Retry · Discard". |
| `onEdit?` | `(comment: GlossComment, body: string) => void \| Promise<unknown>` | — | Comment edited. Return a Promise to hold the editor in a saving state; reject keeps it open with an error. |
| `onDelete?` | `(comment: GlossComment) => void \| Promise<unknown>` | — | Comment deleted (after the confirm popover). Return a Promise to hold the confirm in a pending state. |
| `onReact?` | `(comment: GlossComment, emoji: string, active: boolean) => void \| Promise<unknown>` | — | Reaction toggled (`active` = you now reacted). Applied optimistically; reject reverts it. |
| `onResolve?` | `(comment: GlossComment, resolved: boolean) => void \| Promise<unknown>` | — | Thread resolved (`true`) or reopened (`false`). Applied optimistically; reject reverts it. |
| `onCommentsChange?` | `(comments: GlossComment[]) => void` | — | Fired after every change with the full tree. |
| `linkFor?` | `(comment: GlossComment) => string` | `current page + `#comment-<id>`` | URL copied by "Copy link". |
| `composerPosition?` | `"top" \| "bottom"` | `"top"` | Where the main composer sits. |
| `defaultSort?` | `"oldest" \| "newest"` | `"oldest"` | Initial sort of top-level threads. |
| `collapseAfter?` | `number` | `2` | Replies shown under a long thread before it collapses to "Show N more replies". |
| `title?` | `string` | `"Discussion"` | Header title. |
| `placeholder?` | `string` | `"Add a comment…"` | Composer placeholder. |
| `accentColor?` | `string` | `"#fdba74"` | The single accent: mentions, your reactions, focus rings, send button, new-comment flash. Any CSS color; hex gets auto-contrast ink. |
| `ref?` | `React.Ref<GlossHandle>` | — | Imperative handle — `receive()`, `focusComposer()`, `replay()`, `getComments()`. |
| `className?` | `string` | — | Classes for the root panel — give it a height (e.g. `h-[560px]`) to make the thread scroll inside. |

## Usage example

```tsx
"use client";

import * as React from "react";

import { Gloss, type GlossHandle, type GlossIncoming } from "@/components/crucible/gloss";

const INCOMING: GlossIncoming[] = [
  { parentId: "c-ship", authorId: "theo", body: "Flag is wired up — QA has the build for Thursday. 🚀" },
  { parentId: "c-perf", authorId: "jonas", body: "Re-ran the trace with async decoding: back under budget by ~40ms." },
  { parentId: "c-ship", authorId: "mara", body: "Signing off on design. @[Rae Calder](rae) your call on the rollout %." },
  { authorId: "sol", body: "I'll drop the funnel dashboard here once the flag hits **10%** of new sign-ups." },
];

const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));

export default function GlossDemo() {
  const ref = React.useRef<GlossHandle>(null);
  const next = React.useRef(0);
  const [offline, setOffline] = React.useState(false);
  const offlineRef = React.useRef(offline);
  React.useEffect(() => {
    offlineRef.current = offline;
  }, [offline]);

  const simulate = () => {
    const item = INCOMING[next.current % INCOMING.length];
    next.current += 1;
    ref.current?.receive(item);
  };

  return (
    <div className="relative isolate flex h-full min-h-[680px] w-full flex-col items-center justify-center gap-4 overflow-hidden rounded-xl bg-neutral-950 px-4 py-8">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 -z-10"
        style={{
          background:
            "radial-gradient(50% 45% at 50% 25%, rgba(253,186,116,0.06), transparent 70%), radial-gradient(40% 40% at 50% 100%, rgba(255,255,255,0.03), transparent 70%)",
        }}
      />
      <div className="relative z-10 flex w-full flex-col items-center gap-4">
        <Gloss
          ref={ref}
          className="h-[560px] w-full max-w-[680px]"
          onPost={async () => {
            await wait(900);
            if (offlineRef.current) throw new Error("offline");
          }}
          onEdit={() => wait(500)}
          onDelete={() => wait(500)}
        />
        <div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-2 text-xs text-white/45">
          <button
            type="button"
            onClick={simulate}
            className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.04] px-3 font-medium text-white/80 transition-colors hover:bg-white/[0.08] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
          >
            Simulate reply
          </button>
          <label className="inline-flex h-8 cursor-pointer select-none items-center gap-2 rounded-lg border border-white/10 bg-white/[0.04] px-3 font-medium text-white/70 transition-colors hover:bg-white/[0.08] has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-white/60">
            <input
              type="checkbox"
              checked={offline}
              onChange={(e) => setOffline(e.target.checked)}
              className="peer sr-only"
            />
            <span
              aria-hidden
              className="relative h-3.5 w-6 rounded-full bg-white/15 transition-colors after:absolute after:left-0.5 after:top-0.5 after:size-2.5 after:rounded-full after:bg-white/80 after:transition-transform peer-checked:bg-rose-400/70 peer-checked:after:translate-x-2.5"
            />
            Fail sends
          </label>
          <span>Type @ to mention — react to a comment.</span>
        </div>
      </div>
    </div>
  );
}
```

## Accessibility & performance

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