RRenDSv0.13.0

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-slug primitive 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.

  1. AI slug (.ren-ai-slug) — inline label marking content as AI-generated. Place wherever you'd write "AI" in text.
  2. 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.
  3. Indicators (.ren-ai-typing, .ren-ai-streaming) — shown while the model is generating. The typing dots show "thinking"; streaming pulses the latest token.
  4. Citations + sources (.ren-ai-citation, .ren-ai-sources) — superscript references in the body that link to a list of sources rendered below the message.
  5. Confidence (.ren-ai-confidence) — optional bar showing model confidence (data-level="high|medium|low").
  6. Prompt (.ren-ai-prompt) — the input at the bottom of the thread with a send button.

Live

Demo

What's the difference between a Tooltip and a Popover?
AI Assistant · just now
Tooltip is a passive hint shown on hover or focus — typically just text1. Popover is interactive: it can hold forms, multiple actions, anything keyboard-navigable2. Tooltip never traps focus; Popover does.
Confidence: High
  1. 1 Tooltip — RenDS docs rends.dev/docs/components/ren-tooltip.html
  2. 2 Popover — RenDS docs rends.dev/docs/components/ren-popover.html
Thinking…
<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

AI slug

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>
Streaming

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>
Typing

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>
Skeleton

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>
Confidence

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>
Citations

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>
Prompt input

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

ClassEffect
.ren-ai-slugInline "AI" pill. Use anywhere AI-generated content needs an explicit marker.
.ren-ai-messageAssistant message bubble. Wraps header / content / footer.
.ren-ai-message-headerTop row inside the bubble: slug, author name, timestamp.
.ren-ai-message-contentThe body text. Append streaming tokens here.
.ren-ai-message-footerBottom row: confidence, sources, actions.
.ren-ai-streamingAdd to .ren-ai-message while tokens are streaming. Animates the trailing edge.
.ren-ai-typingPre-streaming "thinking" container. Sibling, not nested, of the streaming message.
.ren-ai-typing-dotsThree pulsing dots inside .ren-ai-typing.
.ren-ai-skeleton / .ren-ai-skeleton-linePlaceholder for a not-yet-loaded message.
.ren-ai-citationOn <sup> inline in content. Renders as a clickable reference number.
.ren-ai-sourcesOrdered list of source entries below the message body.
.ren-ai-sourceOne source list item. Number + title + url.
.ren-ai-source-number / -title / -urlComposable parts of a source entry.
.ren-ai-confidenceConfidence indicator. Set data-level="high" | "medium" | "low".
.ren-ai-confidence-bar / -fillTrack + filled portion of the bar.
.ren-ai-actionsTrailing row of action buttons under a message.
.ren-ai-actionOne action button (copy, regenerate, etc.). Real <button>.
.ren-ai-action-thumbsVariant for thumbs up / down feedback buttons.
.ren-ai-promptWrapper for the composer at the bottom of the thread.
.ren-ai-prompt-inputThe <textarea> styled to grow with content.
.ren-ai-prompt-sendThe send button. Icon-only by convention.

Data attributes

AttributeWhereEffect
data-level.ren-ai-confidencehigh / medium / low. Tints the bar.

JavaScript

None ships with this pattern. The customer code:

  • Appends tokens to .ren-ai-message-content during streaming.
  • Adds / removes .ren-ai-streaming to flip the trailing-edge animation.
  • Wires .ren-ai-action click 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-prompt form 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>