Thread Composer
Write a multi-post thread: each post has its own counter ring, add or remove posts, auto-numbering, and split long text.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/thread-composer.json'use client'
import { useState } from 'react'
import './thread-composer.css'
export interface ThreadComposerProps {
limit?: number
posts?: string[]
onPost?: (posts: string[]) => void
fill?: 'dark' | 'light'
className?: string
}
function cut(t: string, max: number) {
if (t.length <= max) return [t, '']
const s = t.slice(0, max), br = Math.max(s.lastIndexOf('. '), s.lastIndexOf('? '), s.lastIndexOf('! '))
const at = br > max * 0.5 ? br + 1 : Math.max(s.lastIndexOf(' '), Math.floor(max * 0.8))
return [t.slice(0, at).trim(), t.slice(at).trim()]
}
/** Multi-post thread composer with per-post limits. */
export function ThreadComposer({ limit = 280, posts: p0 = [''], onPost, fill = 'dark', className }: ThreadComposerProps) {
const [ps, setPs] = useState(p0.length ? p0 : [''])
const [num, setNum] = useState(true)
const [sent, setSent] = useState(false)
const tag = (i: number) => (num && ps.length > 1 ? ` ${i + 1}/${ps.length}` : '')
const room = (i: number) => limit - ps[i].length - tag(i).length
const bad = ps.some((_, i) => room(i) < 0) || ps.every((p) => !p.trim())
const set = (i: number, v: string) => { setPs(ps.map((p, j) => (j === i ? v : p))); setSent(false) }
const split = (i: number) => { const [a, b] = cut(ps[i], limit - 6); const n = [...ps]; n.splice(i, 1, a, b); setPs(n) }
return (
<div className={['sd-thr', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-thr__h"><b>New thread</b><label><input type="checkbox" checked={num} onChange={(e) => setNum(e.target.checked)} />Number posts</label></div>
<div className="sd-thr__l">
{ps.map((p, i) => {
const r = room(i), C = 2 * Math.PI * 9, frac = Math.min(1, (limit - r) / limit)
return (
<div key={i} className="sd-thr__p" data-over={r < 0 || undefined}>
<span className="sd-thr__av" aria-hidden>{i + 1}</span>
<div>
<textarea aria-label={`Post ${i + 1} of ${ps.length}`} value={p} placeholder={i ? 'Keep going…' : 'Start your thread'} onChange={(e) => set(i, e.target.value)} />
<div className="sd-thr__ft">
<span>{num && ps.length > 1 ? `${i + 1}/${ps.length}` : ''}</span>
{r < 0 && <button type="button" onClick={() => split(i)}>Split</button>}
{ps.length > 1 && <button type="button" aria-label={`Remove post ${i + 1}`} onClick={() => setPs(ps.filter((_, j) => j !== i))}>Remove</button>}
<span className="sd-thr__ring" data-over={r < 0 || undefined} aria-live={r <= 20 ? 'polite' : 'off'} aria-label={`${r} characters left`}>
<svg viewBox="0 0 22 22" aria-hidden><circle className="bg" cx="11" cy="11" r="9" /><circle className="fg" cx="11" cy="11" r="9" strokeDasharray={C} strokeDashoffset={C * (1 - frac)} /></svg>
{r <= 20 && <b>{r}</b>}
</span>
</div>
</div>
</div>
)
})}
</div>
<div className="sd-thr__f">
<button type="button" disabled={ps.length >= 10 || !ps[ps.length - 1].trim()} onClick={() => setPs([...ps, ''])}>+ Add post</button>
<button type="button" data-pri disabled={bad} onClick={() => { setSent(true); onPost?.(ps.map((p, i) => p + tag(i))) }}>{sent ? 'Posted' : ps.length > 1 ? `Post all ${ps.length}` : 'Post'}</button>
</div>
</div>
)
}.sd-thr { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-thr[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-thr :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-thr :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-thr .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-thr { width: min(100%, 340px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 10px; }
.sd-thr__h { display: flex; justify-content: space-between; align-items: center; font-size: 12.5px; color: var(--mute); }
.sd-thr__h b { color: var(--fg); font-size: 15px; }
.sd-thr__h label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
.sd-thr__h input { appearance: none; width: 30px; height: 18px; border-radius: 99px; background: var(--line); position: relative; margin: 0; cursor: pointer; }
.sd-thr__h input::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--fg); transition: transform 160ms; }
.sd-thr__h input:checked { background: var(--fg); }
.sd-thr__h input:checked::after { transform: translateX(12px); background: var(--bg); }
.sd-thr__l { display: grid; gap: 0; max-height: 360px; overflow-y: auto; }
.sd-thr__p { display: grid; grid-template-columns: 30px 1fr; gap: 8px; position: relative; padding-bottom: 10px; animation: sd-thr-in 220ms ease-out; }
@keyframes sd-thr-in { from { opacity: 0; transform: translateY(-6px); } }
.sd-thr__p:not(:last-child)::before { content: ''; position: absolute; left: 14px; top: 34px; bottom: 0; width: 2px; background: var(--line); }
.sd-thr__av { width: 30px; height: 30px; border-radius: 50%; background: var(--soft); display: grid; place-items: center; font-size: 11.5px; font-weight: 700; }
.sd-thr__p textarea { width: 100%; min-height: 64px; padding: 6px 0; border: 0; background: none; color: var(--fg); font: inherit; font-size: 13.5px; line-height: 1.45; resize: none; outline: 0; field-sizing: content; box-sizing: border-box; }
.sd-thr__p[data-over] textarea { text-decoration: underline wavy; text-decoration-thickness: 1px; }
.sd-thr__ft { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--mute); }
.sd-thr__ft button { font-size: 11.5px; font-weight: 600; color: var(--mute) !important; }
.sd-thr__ft button:hover { color: var(--fg) !important; }
.sd-thr__ft span { flex: 1; }
.sd-thr__ring { width: 22px; height: 22px; position: relative; flex: none; }
.sd-thr__ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.sd-thr__ring circle { fill: none; stroke-width: 2.5; }
.sd-thr__ring .bg { stroke: var(--line); }
.sd-thr__ring .fg { stroke: var(--fg); transition: stroke-dashoffset 120ms; }
.sd-thr__ring b { position: absolute; inset: 0; display: grid; place-items: center; font-size: 10.5px; font-variant-numeric: tabular-nums; color: var(--fg); }
.sd-thr__ring[data-over] .bg { stroke: var(--fg); }
.sd-thr__f { display: flex; gap: 6px; }
.sd-thr__f button { flex: 1; height: 40px; border-radius: 11px; font-size: 13px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); }
.sd-thr__f button[data-pri] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-thr__f button:disabled { opacity: 0.4; }
@media (prefers-reduced-motion: reduce) { .sd-thr *, .sd-thr *::before, .sd-thr *::after { animation: none !important; transition: none !important; } }Overview
Posts stack along a connecting line like the published thread will. Each has a small ring that fills toward the limit and turns into a countdown in the last 20 characters.
Split breaks an over-long post at the nearest sentence or word boundary into the next post. Numbering (1/4) can be toggled.
When to use
- Social schedulers, community apps, changelog posts.
When not to use
- Long-form articles.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/thread-composer.json$
npx shadcn@latest add https://sundaeui.dev/r/thread-composer.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/thread-composer.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/thread-composer.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy thread-composer.tsx and thread-composer.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { ThreadComposer } from '@/components/sundae/thread-composer'
<ThreadComposer />Props
| Prop | Type | Default |
|---|---|---|
limitCharacters per post. | number | 280 |
postsStarting posts. | string[] | — |
onPostCalled on publish. | (posts: string[]) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each post is a labelled textarea; remaining characters are announced near the limit.
- Add, remove and split buttons are labelled with the post number.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-thr; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Thread Composer free?
- Yes. Thread Composer is free for personal and commercial projects, with full source on this page.
- How do I install Thread Composer?
- Run npx shadcn@latest add https://sundaeui.dev/r/thread-composer.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Thread Composer support keyboard and screen readers?
- Each post is a labelled textarea; remaining characters are announced near the limit. Add, remove and split buttons are labelled with the post number.
- Does Thread Composer respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Poll Builder
Forms · RefinedCharacter Limit Field
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined - Pro
Wheel Picker
Forms · Refined