Chat
import { useEffect, useState } from 'react';
const css = `
@keyframes card-chat-in { from { opacity: 0; transform: translateY(8px) scale(0.92); } to { opacity: 1; transform: none; } }
@keyframes card-chat-dot { 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } 30% { transform: translateY(-3px); opacity: 1; } }
.card-chat {
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 6px;
width: 236px;
height: 186px;
padding: 12px;
border-radius: 18px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
}
.card-chat__msg {
max-width: 78%;
padding: 7px 10px;
border-radius: 14px;
font-size: 12px;
line-height: 1.35;
animation: card-chat-in 0.35s cubic-bezier(0.34, 1.4, 0.64, 1) both;
}
.card-chat__msg[data-me] { align-self: flex-end; border-bottom-right-radius: 4px; background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); transform-origin: right bottom; }
.card-chat__msg:not([data-me]) { align-self: flex-start; border-bottom-left-radius: 4px; background: light-dark(#ebedf0, #232529); color: light-dark(#373a40, #f1f2f3); transform-origin: left bottom; }
.card-chat__typing { align-self: flex-start; display: flex; gap: 3px; padding: 9px 11px; border-radius: 14px; border-bottom-left-radius: 4px; background: light-dark(#ebedf0, #232529); animation: card-chat-in 0.25s ease both; }
.card-chat__typing i { width: 5px; height: 5px; border-radius: 50%; background: light-dark(#42454c, #e5e6e9); animation: card-chat-dot 1s ease-in-out infinite; }
.card-chat__typing i:nth-child(2) { animation-delay: 0.15s; }
.card-chat__typing i:nth-child(3) { animation-delay: 0.3s; }
@media (prefers-reduced-motion: reduce) {
.card-chat__msg, .card-chat__typing, .card-chat__typing i { animation: none; }
}
`;
const SCRIPT = [
{ me: false, text: 'Did the new build land?' },
{ me: true, text: 'Deployed 2 min ago' },
{ me: false, text: 'The loader is so smooth now' },
{ me: true, text: 'Shipping the cards next' },
];
export function ChatCard() {
// Each message takes two beats: a typing beat (for theirs) and the bubble.
const [beat, setBeat] = useState(0);
useEffect(() => {
const id = window.setInterval(() => setBeat((b) => (b >= SCRIPT.length * 2 + 2 ? 0 : b + 1)), 850);
return () => clearInterval(id);
}, []);
const shown = SCRIPT.slice(0, Math.floor(beat / 2));
const next = SCRIPT[Math.floor(beat / 2)];
const typing = beat % 2 === 1 && next && !next.me;
return (
<>
<style>
{css}
</style>
<div className="card-chat" role="log" aria-label="Chat with Jordan" aria-live="polite">
{shown.map((m) => (
<div key={m.text} className="card-chat__msg" data-me={m.me || undefined}>
{m.text}
</div>
))}
{typing && (
<div className="card-chat__typing" aria-label="Jordan is typing">
<i />
<i />
<i />
</div>
)}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardChat(el) on the mounted element instead.
<div class="card-chat" role="log" aria-label="Chat with Jordan" aria-live="polite"></div>@keyframes card-chat-in { from { opacity: 0; transform: translateY(8px) scale(0.92); } to { opacity: 1; transform: none; } }
@keyframes card-chat-dot { 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } 30% { transform: translateY(-3px); opacity: 1; } }
.card-chat {
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 6px;
width: 236px;
height: 186px;
padding: 12px;
border-radius: 18px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
}
.card-chat__msg {
max-width: 78%;
padding: 7px 10px;
border-radius: 14px;
font-size: 12px;
line-height: 1.35;
animation: card-chat-in 0.35s cubic-bezier(0.34, 1.4, 0.64, 1) both;
}
.card-chat__msg[data-me] { align-self: flex-end; border-bottom-right-radius: 4px; background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); transform-origin: right bottom; }
.card-chat__msg:not([data-me]) { align-self: flex-start; border-bottom-left-radius: 4px; background: light-dark(#ebedf0, #232529); color: light-dark(#373a40, #f1f2f3); transform-origin: left bottom; }
.card-chat__typing { align-self: flex-start; display: flex; gap: 3px; padding: 9px 11px; border-radius: 14px; border-bottom-left-radius: 4px; background: light-dark(#ebedf0, #232529); animation: card-chat-in 0.25s ease both; }
.card-chat__typing i { width: 5px; height: 5px; border-radius: 50%; background: light-dark(#42454c, #e5e6e9); animation: card-chat-dot 1s ease-in-out infinite; }
.card-chat__typing i:nth-child(2) { animation-delay: 0.15s; }
.card-chat__typing i:nth-child(3) { animation-delay: 0.3s; }
@media (prefers-reduced-motion: reduce) {
.card-chat__msg, .card-chat__typing, .card-chat__typing i { animation: none; }
}function initCardChat(root) {
var SCRIPT = [
{ me: false, text: 'Did the new build land?' },
{ me: true, text: 'Deployed 2 min ago' },
{ me: false, text: 'The loader is so smooth now' },
{ me: true, text: 'Shipping the cards next' },
];
// Each message takes two beats: a typing beat (for theirs) and the bubble.
var beat = 0;
function render() {
var count = Math.floor(beat / 2);
var bubbles = root.querySelectorAll('.card-chat__msg');
if (bubbles.length > count) root.textContent = '';
var typing = root.querySelector('.card-chat__typing');
if (typing) typing.remove();
for (var i = root.querySelectorAll('.card-chat__msg').length; i < count; i++) {
var m = document.createElement('div');
m.className = 'card-chat__msg';
if (SCRIPT[i].me) m.setAttribute('data-me', '');
m.textContent = SCRIPT[i].text;
root.appendChild(m);
}
var next = SCRIPT[count];
if (beat % 2 === 1 && next && !next.me) {
var t = document.createElement('div');
t.className = 'card-chat__typing';
t.setAttribute('aria-label', 'Jordan is typing');
t.innerHTML = '<i></i><i></i><i></i>';
root.appendChild(t);
}
}
var timer = setInterval(function () {
beat = beat >= SCRIPT.length * 2 + 2 ? 0 : beat + 1;
render();
}, 850);
return function () { clearInterval(timer); };
}
document.querySelectorAll('.card-chat').forEach(initCardChat);Goal: Create a "Chat" card.
Visual behavior: A conversation that plays itself out: typing dots appear, then each message springs in from its own side, until the thread is full and starts again.
Technique:
- A column pinned to the bottom (justify-content: flex-end), so new bubbles push old ones up.
- A beat counter advances every 850ms. Each message takes two beats: a typing indicator (for the other person's) and then the bubble.
- Bubbles spring in with a scale and rise keyframe from their own bottom corner; the typing dots bounce with staggered delays.
Accessibility: role="log" with aria-live="polite", so each new message is announced; the typing indicator is labelled. Under prefers-reduced-motion bubbles appear without animation.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Chat" card.
Visual behavior: A conversation that plays itself out: typing dots appear, then each message springs in from its own side, until the thread is full and starts again.
Technique:
- A column pinned to the bottom (justify-content: flex-end), so new bubbles push old ones up.
- A beat counter advances every 850ms. Each message takes two beats: a typing indicator (for the other person's) and then the bubble.
- Bubbles spring in with a scale and rise keyframe from their own bottom corner; the typing dots bounce with staggered delays.
Accessibility: role="log" with aria-live="polite", so each new message is announced; the typing indicator is labelled. Under prefers-reduced-motion bubbles appear without animation.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A conversation that plays itself out: typing dots appear, then each message springs in from its own side, until the thread is full and starts again.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- continuous, spring, delay stagger, card
- Techniques
- keyframes, interval, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
role="log" with aria-live="polite", so each new message is announced; the typing indicator is labelled. Under prefers-reduced-motion bubbles appear without animation.