# Ghosted Chat

> Unanswered chat messages marked as read, with a thumbs-down reply on hover

A shadcn/ui block from the [@affectui](https://www.affectui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/affectui/ghosted-chat).

- Page, with a live preview: https://shadcn.exchange/r/affectui/ghosted-chat
- Screenshot: https://shadcn.exchange/shot/affectui/ghosted-chat/light.png
- Registry item: https://www.affectui.com/r/ghosted-chat.json
- Listed: 2026-11-01, source last changed 2026-11-01

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/affectui/ghosted-chat.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Files

### `illustrations/ghosted-chat/ghosted-chat.tsx`

```tsx
"use client";

import { useEffect, useRef, useState } from "react";

/*
 * Ghosted Chat – a small card illustration, in a single self-contained file.
 * Three unanswered outgoing messages, the last one with a red shine and a soft glow, marked "Read".
 * On hover a thumbs-down reply pops in.
 * The scene is laid out on a 360 × 240 artboard and scaled to the width of its container.
 */

const W = 360;

export type GhostedChatProps = {
  className?: string;
  /** Accessible description of the scene */
  label?: string;
};

export function GhostedChat({
  className,
  label = "A chat with three unanswered messages marked as read",
}: GhostedChatProps) {
  const root = useRef<HTMLDivElement>(null);
  const [scale, setScale] = useState(0);
  // The messages arrive one by one once the scene scrolls into view
  const [play, setPlay] = useState(false);

  useEffect(() => {
    const el = root.current;
    if (!el) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setPlay(true);
          observer.disconnect();
        }
      },
      { threshold: 0.35 },
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  // Scale the artboard to the container width
  useEffect(() => {
    const el = root.current;
    if (!el) return;
    const fit = () => setScale(el.clientWidth / W);
    fit();
    const observer = new ResizeObserver(fit);
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  return (
    <div ref={root} className={["ui-gc", className].filter(Boolean).join(" ")} role="img" aria-label={label}>
      {/* React 19 hoists this into <head> once, no matter how many instances render */}
      <style href="ui-ghosted-chat" precedence="default">
        {css}
      </style>
      <div
        className="ui-gc-stage"
        aria-hidden="true"
        data-play={play || undefined}
        style={{ transform: `scale(${scale})`, visibility: scale > 0 ? "visible" : "hidden" }}
      >
        <div className="ui-gc-content">
          <span className="ui-gc-status">No new messages</span>
          <div className="ui-gc-bubbles">
            <div className="ui-gc-bubble">
              <span>Hi team!</span>
            </div>
            <div className="ui-gc-bubble">
              <span>Are you by any chance looking for a recruiter?</span>
            </div>
            <div className="ui-gc-bubble ui-gc-bubble-last">
              <span className="ui-gc-alert">Hello?</span>
            </div>
            <span className="ui-gc-read">Read</span>
          </div>
          <div className="ui-gc-bubble ui-gc-reply">
            <span>👎</span>
          </div>
        </div>
      </div>
    </div>
  );
}

/* ---------- Styles ---------- */

const css = /* css */ `
.ui-gc {
  --ui-gc-bubble: #FFFFFF;
  --ui-gc-bubble-border: #EDEDED;
  --ui-gc-bubble-shadow: 0 2px 6px rgba(28, 29, 31, 0.08), 0 1px 2px rgba(28, 29, 31, 0.05);
  --ui-gc-text: #4A4C52;
  --ui-gc-ink: #202020;
  --ui-gc-status: #9A9DA3;
  --ui-gc-read: #A8ABB1;
  position: relative;
  width: 100%;
  aspect-ratio: 3 / 2;
  overflow: hidden;
  font-family: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
}
.ui-gc *, .ui-gc *::before, .ui-gc *::after { box-sizing: border-box; margin: 0; }

/* Fixed 360 × 240 artboard, scaled from the top-left corner */
.ui-gc-stage {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 1;
  width: 360px;
  height: 240px;
  padding: 22px 22px 0;
  transform-origin: 0 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
}
/* A touch smaller than the frame, so the chat has room to breathe */
.ui-gc-content {
  position: relative;
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  transform: scale(0.86);
  transform-origin: 50% 45%;
}

/* Arrival: everything waits hidden, then fades in one after another */
.ui-gc-status,
.ui-gc-bubbles > * {
  opacity: 0;
}
.ui-gc-stage[data-play] .ui-gc-status {
  animation: ui-gc-fade 500ms ease-out 100ms both;
}
.ui-gc-stage[data-play] .ui-gc-bubbles > .ui-gc-bubble {
  animation: ui-gc-arrive 620ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.ui-gc-stage[data-play] .ui-gc-bubbles > .ui-gc-bubble:nth-child(1) { animation-delay: 500ms; }
.ui-gc-stage[data-play] .ui-gc-bubbles > .ui-gc-bubble:nth-child(2) { animation-delay: 1300ms; }
.ui-gc-stage[data-play] .ui-gc-bubbles > .ui-gc-bubble:nth-child(3) { animation-delay: 2300ms; }
.ui-gc-stage[data-play] .ui-gc-read {
  animation: ui-gc-fade 500ms ease-out 3200ms both;
}
@keyframes ui-gc-arrive {
  from { opacity: 0; transform: translateY(10px) scale(0.92); filter: blur(2px); }
  to { opacity: 1; transform: none; filter: none; }
}
@keyframes ui-gc-fade {
  from { opacity: 0; }
  to { opacity: 1; }
}
.ui-gc-bubbles > .ui-gc-bubble { transform-origin: 100% 100%; }

.ui-gc-status {
  font-size: 11px;
  line-height: 1.4;
  color: var(--ui-gc-status);
  letter-spacing: 0.01em;
  transition: color 0.3s ease;
}
.ui-gc-bubbles {
  width: 100%;
  padding-right: 14px;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 5px;
  transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.ui-gc-bubble {
  max-width: min(68%, 26ch);
  width: fit-content;
  padding: 7px 12px;
  background: var(--ui-gc-bubble);
  border: 1px solid var(--ui-gc-bubble-border);
  border-radius: 14px 14px 4px 14px;
  box-shadow: var(--ui-gc-bubble-shadow);
  font-size: 12.5px;
  line-height: 1.3;
  font-weight: 500;
  color: var(--ui-gc-text);
}

/* "Hello?" – a red shine sweeps through the text */
.ui-gc-alert {
  display: inline-block;
  font-weight: 500;
  background-image: linear-gradient(90deg, var(--ui-gc-ink) 0%, var(--ui-gc-ink) 38%, #FF4D47 50%, var(--ui-gc-ink) 62%, var(--ui-gc-ink) 100%);
  background-size: 300% 100%;
  background-position: 100% 0;
  background-repeat: no-repeat;
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  color: transparent;
  animation: ui-gc-shine 4.5s ease-in-out infinite;
}
@keyframes ui-gc-shine {
  0% { background-position: 100% 0; }
  35%, 100% { background-position: 0% 0; }
}

/* Last bubble: soft colored glow underneath */
.ui-gc-bubble-last {
  position: relative;
  isolation: isolate;
  padding: 6px 11px;
  margin-bottom: 6px;
  /* The surface moves to ::before so the glow can sit behind it */
  background: transparent;
  border-color: transparent;
  box-shadow: none;
}
.ui-gc-bubble-last::before {
  content: "";
  position: absolute;
  inset: -1px;
  z-index: -1;
  background: var(--ui-gc-bubble);
  border: 1px solid var(--ui-gc-bubble-border);
  border-radius: inherit;
  box-shadow: var(--ui-gc-bubble-shadow);
}
.ui-gc-bubble-last::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: -6px;
  transform: translateX(-50%);
  width: 100%;
  height: 22px;
  background: linear-gradient(90deg, #FFA0AB 0%, #FFB89E 33%, #C8A8E5 66%, #A8B5FF 100%);
  opacity: 0.6;
  filter: blur(12px);
  border-radius: 50%;
  pointer-events: none;
  z-index: -2;
}
.ui-gc-read {
  font-size: 10.5px;
  line-height: 1.4;
  color: var(--ui-gc-read);
  margin-top: 2px;
  transition: color 0.3s ease;
}

/* Thumbs-down reply – hidden, pops in on hover */
.ui-gc-reply {
  position: absolute;
  left: 64px; /* from the padded chat column */
  bottom: 68px;
  max-width: none;
  width: auto;
  padding: 6px 12px;
  background: #155EEF;
  border: 1px solid #155EEF;
  border-radius: 14px 14px 14px 4px;
  color: #FFFFFF;
  font-size: 15px;
  line-height: 1.1;
  box-shadow: 0 4px 12px rgba(21, 94, 239, 0.22), 0 1px 2px rgba(21, 94, 239, 0.18);
  opacity: 0;
  transform: translateY(14px) scale(0.92);
  transform-origin: bottom left;
  transition: opacity 0.35s ease, transform 0.45s cubic-bezier(0.34, 1.4, 0.64, 1);
  pointer-events: none;
}
/* Hover hides the status texts via their color, so the arrival animation (opacity) is left alone */
.ui-gc:hover .ui-gc-status,
.ui-gc:hover .ui-gc-read { color: transparent; }
.ui-gc:hover .ui-gc-bubbles { transform: translateY(-12px); }
.ui-gc:hover .ui-gc-reply { opacity: 1; transform: translateY(0) scale(1); }

@media (prefers-reduced-motion: reduce) {
  .ui-gc-alert { animation: none; background-position: 0% 0; }
  .ui-gc-status, .ui-gc-bubbles, .ui-gc-read, .ui-gc-reply { transition: none; }
  .ui-gc-status, .ui-gc-bubbles > * { opacity: 1; animation: none !important; }
}

/* Dark mode: follows a .dark class or data-theme="dark" on any ancestor, e.g. <html> */
:where(.dark, [data-theme="dark"]) .ui-gc {
  --ui-gc-bubble: #232326;
  --ui-gc-bubble-border: rgba(255, 255, 255, 0.08);
  --ui-gc-bubble-shadow: 0 2px 6px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.4);
  --ui-gc-text: #c4c4c4;
  --ui-gc-ink: #ededed;
  --ui-gc-status: #8f8f8f;
  --ui-gc-read: #8f8f8f;
}
:where(.dark, [data-theme="dark"]) .ui-gc-bubble-last::after { opacity: 0.45; }
:where(.dark, [data-theme="dark"]) .ui-gc-reply {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.4);
}
`;
```
