Composer · proposal
Theme

Draft for discussion, not a spec

A composer component for daisyUI

Follow-up to #4824: a composer component, the field that holds a textarea, buttons and dropdowns. Taken from a working implementation and written in daisyUI's conventions. Everything here is open for discussion.

Plain HTML and CSS. It grows as you type, no JS.

What's daisyUI, what isn't

As discussed in the thread: daisyUI does the visuals. Behavior and the server side live outside.

Proposed for daisyUI

The field: border, radius, focus ring, an auto-growing textarea, a toolbar row and two states. CSS only, theme tokens only.

composercomposer-toolbarcomposer-busycomposer-dragover--composer-max-height

daisyUI today

Everything inside the field.

btndropdownmenutoggleradiojointooltipstatusradial-progress

Outside daisyUI

Behavior: mic/send/stop toggle, Enter to send, uploads, push-to-talk. Alpine.js in my apps.

Server: posts the message, streams the answer (HTMX). The composer only sees busy and idle.

States

Static markup, no JS. JS or the server picks the state; CSS draws it. Try the theme switcher.

Idle · empty, mic shown
With content · mic → send
Recording · textarea → timer, daisyUI's status
0:04 Release to send · slide off to cancel
Busy · composer-busy, send → stop
Drag-over · composer-dragover
Disabled · a disabled textarea dims the field via :has()

Markup

The idle state from above. Proposed classes are marked; the rest is stock daisyUI plus a few layout utilities.

ClassWhat it does
composerThe field. Theme border, radius and focus ring. A direct textarea child loses its frame and grows with its content, up to --composer-max-height (12rem).
composer-toolbarRow of controls below the textarea; wraps.
composer-busyPrimary border with a sweeping highlight (static under prefers-reduced-motion).
composer-dragoverDashed primary border, tinted background.

The CSS

Base class in daisyui.l1.l2.l3, modifiers in daisyui.l1.l2 so they win. Only theme tokens: --border, --radius-box, --depth, --color-*.

@property --composer-sweep {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
@layer utilities {
  @layer daisyui.l1.l2.l3 {
    .composer {
      position: relative;
      display: flex;
      flex-direction: column;
      gap: 0.5rem;
      width: 100%;
      padding: 0.5rem;
      border: var(--border) solid color-mix(in oklab, var(--color-base-content) 15%, transparent);
      border-radius: var(--radius-box);
      background-color: var(--color-base-100);
      box-shadow: 0 1px 2px color-mix(in oklab, var(--color-base-content) calc(var(--depth) * 10%), transparent);
      @media (prefers-reduced-motion: no-preference) {
        transition: border-color 0.2s, box-shadow 0.2s, background-color 0.2s;
      }
      &:focus-within {
        border-color: color-mix(in oklab, var(--color-base-content) 35%, transparent);
        box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-base-content) 8%, transparent);
      }
      &:has(> textarea:disabled) {
        cursor: not-allowed;
        opacity: 0.7;
      }
      & > textarea {
        appearance: none;
        width: 100%;
        min-height: 2.75rem;
        max-height: var(--composer-max-height, 12rem);
        padding: 0.25rem 0.5rem;
        border: 0;
        outline: none;
        resize: none;
        background: transparent;
        color: inherit;
        font: inherit;
        line-height: 1.5;
        field-sizing: content;
        &::placeholder {
          color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
        }
      }
    }
    .composer-toolbar {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      gap: 0.25rem;
    }
  }
  @layer daisyui.l1.l2 {
    .composer-dragover {
      border-style: dashed;
      border-color: var(--color-primary);
      background-color: color-mix(in oklab, var(--color-primary) 6%, var(--color-base-100));
    }
    .composer-busy {
      border-color: color-mix(in oklab, var(--color-primary) 30%, transparent);
      &::before {
        content: "";
        position: absolute;
        inset: calc(var(--border) * -1);
        padding: calc(var(--border) + 0.5px);
        border-radius: inherit;
        background: conic-gradient(from var(--composer-sweep), transparent 0% 60%, var(--color-primary) 80%, transparent 100%);
        mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
        pointer-events: none;
        @media (prefers-reduced-motion: no-preference) {
          animation: composer-sweep 2.4s linear infinite;
        }
      }
    }
  }
}
@keyframes composer-sweep {
  to {
    --composer-sweep: 360deg;
  }
}

With behavior

How my apps use it: + menu, tool chips, context meter, model picker and one action button (mic, send or stop). The behavior is a small Alpine.js component, not part of this proposal. Sending, uploads and recording are faked here: type and hit Enter, add a file, hold the mic, hit stop.

0:00 Release to send · slide off or press Esc to cancel

File chips use a separate attachment class, not part of this proposal.

How it fits together

All of this runs in a working implementation (Django, Pydantic AI). I'm planning to propose the rest as a separate open-source project: standardize everything above daisyUI, so daisyUI only has to draw.

server

Agent SDK

Pydantic AI, LangGraph, …

↓ adapter

server

Run events

thinking, text, tool call, done

↓ renderer

server

HTML fragments

copy-paste, daisyUI classes

↓ stream

browser

HTMX + Alpine

swap in, add behavior

↓

browser

daisyUI

draws

Only the last step is daisyUI's.
Adapter
Translates one agent SDK's stream into neutral run events. One per SDK; the only part that knows the SDK.
Renderer
Turns run events into HTML fragments and decides where each one lands: appended, or updating one already on the page.

Copy-paste fragments

Plain HTML snippets with daisyUI classes, one per component state. Dropped into Django/Jinja templates and owned by the project, like shadcn/ui.

Streaming contract

Defines what goes over the wire: HTML fragments via SSE, stable element IDs, fixed form fields. Any backend implementing it works with the same fragments.

Event-to-component map

A small set of SDK-agnostic events (thinking, text, tool call, done), each mapped to a fragment. Adapters emit them, so nothing downstream depends on the agent SDK.