Commit Composer
Write a conventional commit: type, scope, a subject held to 50 characters, a wrapped body, and a breaking-change footer.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/commit-composer.json'use client'
import { useState } from 'react'
import './commit-composer.css'
export interface CommitComposerProps {
type?: string
scopes?: string[]
onCommit?: (message: string) => void
fill?: 'dark' | 'light'
className?: string
}
const TYPES = ['feat', 'fix', 'docs', 'refactor', 'perf', 'test', 'chore']
const wrap = (s: string, n = 72) => s.split('\n').map((p) => { const out: string[] = []; let line = ''; p.split(' ').forEach((w) => { if ((line + ' ' + w).trim().length > n) { out.push(line.trim()); line = w } else line += ' ' + w }); out.push(line.trim()); return out.join('\n') }).join('\n')
/** Conventional commit message composer. */
export function CommitComposer({ type: t0 = 'feat', scopes = ['registry', 'demo', 'catalog', 'docs'], onCommit, fill = 'dark', className }: CommitComposerProps) {
const [type, setType] = useState(t0)
const [scope, setScope] = useState(scopes[0] ?? '')
const [subj, setSubj] = useState('')
const [body, setBody] = useState('')
const [brk, setBrk] = useState(false)
const [note, setNote] = useState('')
const [done, setDone] = useState(false)
const clean = subj.trim().replace(/\.$/, '')
const head = `${type}${scope ? `(${scope})` : ''}${brk ? '!' : ''}: ${clean || '…'}`
const msg = [head, body.trim() && wrap(body.trim()), brk && `BREAKING CHANGE: ${note.trim() || 'describe what breaks'}`].filter(Boolean).join('\n\n')
const len = head.length
return (
<div className={['sd-cmt', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-cmt__ty" role="radiogroup" aria-label="Type">{TYPES.map((t) => <button key={t} type="button" role="radio" aria-checked={type === t} onClick={() => { setType(t); setDone(false) }}>{t}</button>)}</div>
<div className="sd-cmt__r"><input aria-label="Scope" list="sd-cmt-sc" placeholder="scope" value={scope} onChange={(e) => setScope(e.target.value.replace(/[^\w-]/g, ''))} /><datalist id="sd-cmt-sc">{scopes.map((s) => <option key={s} value={s} />)}</datalist>
<input aria-label="Subject" aria-describedby="sd-cmt-ct" placeholder="add light fill to cards" value={subj} maxLength={72} onChange={(e) => { setSubj(e.target.value); setDone(false) }} /></div>
<div className="sd-cmt__ct" id="sd-cmt-ct"><span>Imperative, lower case, no period</span><b data-warn={len > 50 || undefined}>{len}/50</b></div>
<textarea aria-label="Body" placeholder="Why this change? (optional)" value={body} onChange={(e) => setBody(e.target.value)} />
<label className="sd-cmt__br"><input type="checkbox" checked={brk} onChange={() => setBrk(!brk)} />Breaking change</label>
{brk && <input aria-label="What breaks" placeholder="onPick renamed to onSelect" value={note} onChange={(e) => setNote(e.target.value)} />}
<pre aria-label="Commit message preview">{msg.split('\n').map((l, i) => <span key={i}>{i === 0 ? <b>{l}</b> : l}{'\n'}</span>)}</pre>
<button type="button" className="sd-cmt__go" disabled={!clean} onClick={() => { setDone(true); onCommit?.(msg) }}>{done ? 'Committed' : 'Commit'}</button>
</div>
)
}.sd-cmt { --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-cmt[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-cmt :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-cmt :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-cmt .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-cmt { width: min(100%, 350px); 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: 8px; }
.sd-cmt__ty { display: flex; flex-wrap: wrap; gap: 4px; }
.sd-cmt__ty button { height: 26px; padding: 0 9px; border-radius: 7px; font: 12px ui-monospace, monospace; box-shadow: inset 0 0 0 1px var(--line); color: var(--mute) !important; }
.sd-cmt__ty button[aria-checked='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-cmt__r { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 6px; }
.sd-cmt input, .sd-cmt textarea { min-width: 0; border: 0; border-radius: 9px; background: var(--soft); color: var(--fg); font: 14px ui-monospace, monospace; outline: 0; padding: 0 9px; }
.sd-cmt input { height: 36px; }
.sd-cmt textarea { height: 60px; resize: none; padding: 8px 9px; font-size: 13px; }
.sd-cmt input:focus, .sd-cmt textarea:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-cmt__ct { display: flex; justify-content: space-between; font-size: 11px; color: var(--mute); margin-top: -4px; }
.sd-cmt__ct b[data-warn] { color: var(--fg); text-decoration: underline; text-underline-offset: 2px; }
.sd-cmt__br { display: flex; align-items: center; gap: 8px; font-size: 12.5px; cursor: pointer; }
.sd-cmt__br input { appearance: none; width: 18px; height: 18px; border-radius: 5px; box-shadow: inset 0 0 0 1.5px var(--mute); padding: 0; display: grid; place-items: center; background: none; }
.sd-cmt__br input:checked { background: var(--fg); box-shadow: none; }
.sd-cmt__br input:checked::after { content: ''; width: 8px; height: 4px; border: 2px solid var(--bg); border-top: 0; border-right: 0; transform: rotate(-45deg) translate(1px, -1px); }
.sd-cmt pre { margin: 0; padding: 10px; border-radius: 10px; box-shadow: inset 0 0 0 1px var(--line); font: 11.5px/1.5 ui-monospace, monospace; white-space: pre; overflow-x: auto; max-height: 120px; }
.sd-cmt pre b { font-weight: 700; }
.sd-cmt__go { height: 40px; border-radius: 11px; font-size: 13px; font-weight: 600;background: var(--strong) !important; color: var(--on) !important; }
.sd-cmt__go:disabled { opacity: 0.4; }
@media (prefers-reduced-motion: reduce) { .sd-cmt *, .sd-cmt *::before, .sd-cmt *::after { animation: none !important; transition: none !important; } }Overview
Type chips cover feat, fix, docs, refactor, perf, test and chore. The subject counter warns past 50 and blocks past 72, and a trailing period is removed.
The body is wrapped at 72 columns in the preview; marking it breaking adds the ! and a BREAKING CHANGE footer.
When to use
- Git GUIs, PR tools, onboarding to commit conventions.
When not to use
- Teams without commit conventions.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/commit-composer.json$
npx shadcn@latest add https://sundaeui.dev/r/commit-composer.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/commit-composer.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/commit-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 commit-composer.tsx and commit-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 { CommitComposer } from '@/components/sundae/commit-composer'
<CommitComposer />Props
| Prop | Type | Default |
|---|---|---|
scopesSuggested scopes. | string[] | — |
onCommitCalled with the final message. | (message: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Type is a radio group; the subject counter is tied to the field with aria-describedby.
- The preview is a code block.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-cmt; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Commit Composer free?
- Yes. Commit Composer is free for personal and commercial projects, with full source on this page.
- How do I install Commit Composer?
- Run npx shadcn@latest add https://sundaeui.dev/r/commit-composer.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Commit Composer support keyboard and screen readers?
- Type is a radio group; the subject counter is tied to the field with aria-describedby. The preview is a code block.
- Does Commit Composer respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →OTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined - Pro
Wheel Picker
Forms · Refined Password Strength
Forms · RefinedRating Scale
Forms · Refined