Pattern
AI Patterns CSS-only
CSS-only pattern. No Web Component, no colocated JS — bring your own inference layer. Visual primitives for chat-style AI surfaces: assistant message bubbles, streaming + typing indicators, inline citations, source lists, confidence bars, prompt input, and action affordances (copy, regenerate, thumbs).
About
Overview
AI chat surfaces share a small visual vocabulary: a thread of assistant messages, a streaming response indicator while the model is thinking, inline citations linking to source documents, an action row under the latest message (copy, regenerate, thumbs up/down), and a prompt input at the bottom. RenDS provides every one of those as a stand-alone CSS primitive you can compose freely.
Important: this is a CSS-only pattern. There is no <ren-ai> custom element, no JavaScript file that ships with the package, and no streaming or state machine baked in. You bring:
- The inference call (OpenAI, Anthropic, your own backend, etc.).
- The streaming text update logic (append tokens to
.ren-ai-message-content). - The action handlers (copy → clipboard API, thumbs → your feedback endpoint, regenerate → re-call the model).
- The thread state (which message is streaming, message history, scroll-to-bottom).
RenDS does the visual + accessibility plumbing — tokens, layout, focus rings, live regions, reduced-motion fallbacks — so your application code focuses on the model logic, not the chrome.
When to use
- Building a chat-style assistant UI (in-app help, support agent, document Q&A).
- Adding an "Ask AI" panel next to existing content.
- Rendering AI-generated text with provenance (citations + sources).
When not to use
- Static text labeled "AI-generated". Use a Badge or the
.ren-ai-slugprimitive inline instead. - Form-style "AI assist". If the AI just fills a field, use Field with a custom button — no chat shell needed.
- Real-time co-edit / multiplayer. Out of scope — this pattern assumes turn-based request → response.
Visual primitives, not a chat engine. If you need a higher-level component that manages thread state, streaming, and message history, build it on top using the classes here. The intent is to give you the look without imposing a state shape.
Structure
Anatomy
A complete AI surface has six possible regions. Pick what you need.
- AI slug (
.ren-ai-slug) — inline label marking content as AI-generated. Place wherever you'd write "AI" in text. - Message (
.ren-ai-message) — assistant bubble. Header (avatar / title), content (the text), footer (actions). User messages style themselves — keep them as plain bubbles or use your own. - Indicators (
.ren-ai-typing,.ren-ai-streaming) — shown while the model is generating. The typing dots show "thinking"; streaming pulses the latest token. - Citations + sources (
.ren-ai-citation,.ren-ai-sources) — superscript references in the body that link to a list of sources rendered below the message. - Confidence (
.ren-ai-confidence) — optional bar showing model confidence (data-level="high|medium|low"). - Prompt (
.ren-ai-prompt) — the input at the bottom of the thread with a send button.
Live
Demo
<div role="log" aria-live="polite" aria-label="Conversation with assistant">
<!-- assistant message -->
<div class="ren-ai-message">
<header class="ren-ai-message-header">
<span class="ren-ai-slug">AI</span>
<span>Assistant · just now</span>
</header>
<div class="ren-ai-message-content">
Tooltip is passive<sup class="ren-ai-citation">1</sup>.
Popover is interactive<sup class="ren-ai-citation">2</sup>.
</div>
<div class="ren-ai-confidence" data-level="high">
<span>Confidence: High</span>
<div class="ren-ai-confidence-bar">
<div class="ren-ai-confidence-fill"></div>
</div>
</div>
<ol class="ren-ai-sources">
<li class="ren-ai-source">
<span class="ren-ai-source-number">1</span>
<a class="ren-ai-source-title" href="…">Tooltip — RenDS docs</a>
<span class="ren-ai-source-url">rends.dev/…</span>
</li>
</ol>
<footer class="ren-ai-message-footer">
<div class="ren-ai-actions">
<button class="ren-ai-action">📋 Copy</button>
<button class="ren-ai-action">↻ Regenerate</button>
<button class="ren-ai-action ren-ai-action-thumbs">👍</button>
</div>
</footer>
</div>
<!-- typing indicator (while model is generating) -->
<div class="ren-ai-typing" aria-live="polite">
<span class="ren-ai-typing-dots"><span></span><span></span><span></span></span>
<span>Thinking…</span>
</div>
</div>
Visual
Variants
Inline marker that flags AI-generated content. Wear it on a button, in copy, or on a card.
This text was AI generated.
<span class="ren-ai-slug">AI</span>
Add .ren-ai-streaming on the message bubble while tokens are being appended to .ren-ai-message-content. It animates the trailing edge as a "still talking" cue.
<div class="ren-ai-message ren-ai-streaming">
<div class="ren-ai-message-content">The capital of France is Pa</div>
</div>
Pre-streaming "thinking" state. Three pulsing dots inside .ren-ai-typing-dots.
<div class="ren-ai-typing" aria-live="polite">
<span class="ren-ai-typing-dots"><span></span><span></span><span></span></span>
<span>Thinking…</span>
</div>
Placeholder for a message that hasn't started streaming yet (e.g. initial load of a saved thread).
<div class="ren-ai-message">
<div class="ren-ai-skeleton">
<div class="ren-ai-skeleton-line"></div>
<div class="ren-ai-skeleton-line"></div>
</div>
</div>
Three levels via data-level. The bar fills proportionally and color-codes (high → success, medium → warning, low → muted).
<div class="ren-ai-confidence" data-level="high|medium|low">
<span>Confidence: High</span>
<div class="ren-ai-confidence-bar">
<div class="ren-ai-confidence-fill"></div>
</div>
</div>
Inline <sup class="ren-ai-citation"> references that link to the sources list under the message.
The capital is Paris<sup class="ren-ai-citation">1</sup>.
<ol class="ren-ai-sources">
<li class="ren-ai-source">
<span class="ren-ai-source-number">1</span>
<a class="ren-ai-source-title" href="…">World Atlas, 2024</a>
<span class="ren-ai-source-url">example.com/atlas</span>
</li>
</ol>
Sticky composer at the bottom of the thread. Plain <textarea> + send <button> wrapped in .ren-ai-prompt.
<form class="ren-ai-prompt" action="/api/chat" method="post">
<textarea class="ren-ai-prompt-input" name="prompt"
placeholder="Ask anything…"
rows="1"></textarea>
<button type="submit" class="ren-ai-prompt-send" aria-label="Send">→</button>
</form>
Reference
API
CSS classes
| Class | Effect |
|---|---|
.ren-ai-slug | Inline "AI" pill. Use anywhere AI-generated content needs an explicit marker. |
.ren-ai-message | Assistant message bubble. Wraps header / content / footer. |
.ren-ai-message-header | Top row inside the bubble: slug, author name, timestamp. |
.ren-ai-message-content | The body text. Append streaming tokens here. |
.ren-ai-message-footer | Bottom row: confidence, sources, actions. |
.ren-ai-streaming | Add to .ren-ai-message while tokens are streaming. Animates the trailing edge. |
.ren-ai-typing | Pre-streaming "thinking" container. Sibling, not nested, of the streaming message. |
.ren-ai-typing-dots | Three pulsing dots inside .ren-ai-typing. |
.ren-ai-skeleton / .ren-ai-skeleton-line | Placeholder for a not-yet-loaded message. |
.ren-ai-citation | On <sup> inline in content. Renders as a clickable reference number. |
.ren-ai-sources | Ordered list of source entries below the message body. |
.ren-ai-source | One source list item. Number + title + url. |
.ren-ai-source-number / -title / -url | Composable parts of a source entry. |
.ren-ai-confidence | Confidence indicator. Set data-level="high" | "medium" | "low". |
.ren-ai-confidence-bar / -fill | Track + filled portion of the bar. |
.ren-ai-actions | Trailing row of action buttons under a message. |
.ren-ai-action | One action button (copy, regenerate, etc.). Real <button>. |
.ren-ai-action-thumbs | Variant for thumbs up / down feedback buttons. |
.ren-ai-prompt | Wrapper for the composer at the bottom of the thread. |
.ren-ai-prompt-input | The <textarea> styled to grow with content. |
.ren-ai-prompt-send | The send button. Icon-only by convention. |
Data attributes
| Attribute | Where | Effect |
|---|---|---|
data-level | .ren-ai-confidence | high / medium / low. Tints the bar. |
JavaScript
None ships with this pattern. The customer code:
- Appends tokens to
.ren-ai-message-contentduring streaming. - Adds / removes
.ren-ai-streamingto flip the trailing-edge animation. - Wires
.ren-ai-actionclick handlers (clipboard for copy, callback for regenerate / thumbs). - Manages the thread state (which message is active, scroll-to-bottom, save history).
- Handles the
.ren-ai-promptform submission (call the model, render the response).
Coming in 0.9.0: selectors for error messages (.ren-ai-error), file upload chips (.ren-ai-file-chip), tool-call display (.ren-ai-tool-call), and edit-and-resend (.ren-ai-action-edit). Still CSS-only — same philosophy.
Inclusive by default
Accessibility
Live regions
- Wrap the thread in
role="log"+aria-live="polite". New messages are announced as they appear. - For long streaming responses, announce the final text once when complete, not character by character. Strategy: render the streaming text inside an off-live container, then move it into a live region when streaming finishes.
- The typing indicator carries its own
aria-live="polite"+aria-label="Assistant is typing"so it's announced once.
Focus management
- Don't auto-scroll while the user is reading. Pause auto-scroll when the user scrolls up from the bottom; resume when they reach the bottom again.
- Don't move focus on each new message — focus stays where the user put it.
- Do move focus to the prompt input after submitting (the user is likely typing the next message).
Action buttons
- Always include an accessible name on icon-only actions (
aria-label="Copy response",aria-label="Regenerate"). - Toggle state on thumbs:
aria-pressed="true"when the user clicked it. - "Copy" is the most-used affordance — make sure it works with no extra modal.
Citations + sources
- The citation
<sup>should be a real<a href="#source-N">so keyboard / screen-reader users can jump to the source list. - Source URLs should be readable to screen readers — don't replace the entire URL with an icon.
Reduced motion
All animations (streaming pulse, typing dots, skeleton shimmer) respect prefers-reduced-motion: reduce and fall back to static / opacity-only states. You don't have to wire this — the CSS handles it.
Always provide a way out. An AI thread without a "stop generating" button traps the user when the model goes off the rails. Include a cancel affordance — RenDS doesn't enforce it but it's the most important AI-UX rule.
Patterns
Examples
Streaming a response
Render an empty .ren-ai-message with .ren-ai-streaming while you append tokens to .ren-ai-message-content.
const msg = document.createElement('div');
msg.className = 'ren-ai-message ren-ai-streaming';
msg.innerHTML = `
<header class="ren-ai-message-header">
<span class="ren-ai-slug">AI</span>
</header>
<div class="ren-ai-message-content"></div>
`;
thread.append(msg);
const target = msg.querySelector('.ren-ai-message-content');
for await (const chunk of streamFromModel(prompt)) {
target.append(chunk);
}
// done streaming
msg.classList.remove('ren-ai-streaming');
Copy action with clipboard API
thread.addEventListener('click', async (e) => {
const btn = e.target.closest('.ren-ai-action');
if (!btn || btn.dataset.action !== 'copy') return;
const msg = btn.closest('.ren-ai-message');
const text = msg.querySelector('.ren-ai-message-content').innerText;
await navigator.clipboard.writeText(text);
toast.success('Copied');
});
Thumbs feedback with aria-pressed
// Markup
<button type="button"
class="ren-ai-action ren-ai-action-thumbs"
aria-pressed="false"
aria-label="Helpful">👍</button>
// JS
btn.addEventListener('click', () => {
const pressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!pressed));
fetch('/api/feedback', {
method: 'POST',
body: JSON.stringify({ messageId, helpful: !pressed }),
});
});
Suggestion chips
Use Tag with the clickable variant — there's no AI-specific suggestion class, suggestions are just tags.
<div style="display: flex; flex-wrap: wrap; gap: var(--space-1);">
<button class="ren-tag ren-tag-clickable">Show me an example</button>
<button class="ren-tag ren-tag-clickable">What about Dialog?</button>
</div>