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.
statuscomposer-busy, send → stopcomposer-dragover:has()Markup
The idle state from above. Proposed classes are marked; the rest is stock daisyUI plus a few layout utilities.
| Class | What it does |
|---|---|
| composer | The 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-toolbar | Row of controls below the textarea; wraps. |
| composer-busy | Primary border with a sweeping highlight (static under prefers-reduced-motion). |
| composer-dragover | Dashed 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.
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
- 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.