Branch Picker
A git branch switcher: search, branches and tags tabs, recent first, ahead/behind counts, and create-from-current.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/branch-picker.json'use client'
import { useRef, useState } from 'react'
import './branch-picker.css'
export interface Branch { name: string; ahead: number; behind: number; recent?: boolean }
export interface BranchPickerProps {
branches: Branch[]
tags?: string[]
value?: string
defaultBranch?: string
defaultOpen?: boolean
onChange?: (name: string) => void
fill?: 'dark' | 'light'
className?: string
}
const BrIcon = () => <svg viewBox="0 0 16 16" aria-hidden><circle cx="4" cy="3.5" r="1.7" /><circle cx="4" cy="12.5" r="1.7" /><circle cx="12" cy="5" r="1.7" /><path d="M4 5.2v5.6M12 6.7c0 3-4 2.3-7.3 4.6" /></svg>
const Check = () => <svg viewBox="0 0 16 16" aria-hidden><path d="M3 8.5l3 3 7-7" /></svg>
/** Git branch and tag switcher with create. */
export function BranchPicker({ branches, tags = [], value = 'main', defaultBranch = 'main', defaultOpen = false, onChange, fill = 'dark', className }: BranchPickerProps) {
const [list, setList] = useState(branches)
const [cur, setCur] = useState(value)
const [open, setOpen] = useState(defaultOpen)
const [q, setQ] = useState('')
const [tab, setTab] = useState<'branches' | 'tags'>('branches')
const [act, setAct] = useState(0)
const trig = useRef<HTMLButtonElement>(null)
const pool = tab === 'branches' ? list.map((b) => b.name) : tags
const hits = pool.filter((n) => n.toLowerCase().includes(q.trim().toLowerCase()))
const recent = tab === 'branches' && !q ? list.filter((b) => b.recent).map((b) => b.name) : []
const rest = hits.filter((n) => !recent.includes(n))
const create = tab === 'branches' && q.trim() && !pool.includes(q.trim()) ? q.trim().replace(/\s+/g, '-') : null
const rows = [...(create ? [`+${create}`] : []), ...recent, ...rest]
const choose = (row: string) => {
if (row.startsWith('+')) { const n = row.slice(1); setList((l) => [{ name: n, ahead: 0, behind: 0, recent: true }, ...l]); setCur(n); onChange?.(n) }
else { setCur(row); onChange?.(row) }
setOpen(false); setQ(''); trig.current?.focus()
}
const meta = (n: string) => list.find((b) => b.name === n)
const opt = (row: string, i: number) => {
const isNew = row.startsWith('+'), n = isNew ? row.slice(1) : row, m = meta(n)
return (
<li key={row} id={`sd-brp-${i}`} role="option" aria-selected={cur === n} className="sd-brp__o" data-act={act === i || undefined} onMouseEnter={() => setAct(i)} onClick={() => choose(row)}>
{cur === n ? <Check /> : <BrIcon />}
<span>{isNew ? <>Create <b>{n}</b> from {cur}</> : n}</span>
{n === defaultBranch && !isNew && <em>default</em>}
{m && !isNew && n !== defaultBranch && <small>{m.ahead}↑ {m.behind}↓</small>}
</li>
)
}
return (
<div className={['sd-brp', className].filter(Boolean).join(' ')} data-fill={fill}>
<button ref={trig} type="button" className="sd-brp__tr" aria-haspopup="listbox" aria-expanded={open} onClick={() => setOpen((o) => !o)}><BrIcon /><span>{cur}</span>▾</button>
{open && (
<div className="sd-brp__p">
<input autoFocus role="combobox" aria-expanded aria-controls="sd-brp-list" aria-activedescendant={`sd-brp-${act}`} aria-label="Find or create a branch" placeholder="Find or create a branch…" value={q}
onChange={(e) => { setQ(e.target.value); setAct(0) }}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setAct((a) => Math.min(rows.length - 1, a + 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setAct((a) => Math.max(0, a - 1)) }
else if (e.key === 'Enter' && rows[act]) { e.preventDefault(); choose(rows[act]) }
else if (e.key === 'Escape') { setOpen(false); trig.current?.focus() }
}} />
<div className="sd-brp__tabs" role="tablist">{(['branches', 'tags'] as const).map((t) => <button key={t} type="button" role="tab" aria-selected={tab === t} onClick={() => { setTab(t); setAct(0) }}>{t[0].toUpperCase() + t.slice(1)}</button>)}</div>
<ul id="sd-brp-list" role="listbox" aria-label={tab}>
{create && opt(`+${create}`, 0)}
{recent.length > 0 && <li className="sd-brp__h" role="presentation">Recent</li>}
{recent.map((r, i) => opt(r, i + (create ? 1 : 0)))}
{recent.length > 0 && rest.length > 0 && <li className="sd-brp__h" role="presentation">All</li>}
{rest.map((r, i) => opt(r, i + recent.length + (create ? 1 : 0)))}
{!rows.length && <li className="sd-brp__none">Nothing matches</li>}
</ul>
</div>
)}
</div>
)
}.sd-brp { --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-brp[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-brp :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-brp :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-brp .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-brp { position: relative; width: min(100%, 320px); }
.sd-brp__tr { display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 12px; border-radius: 10px; box-shadow: inset 0 0 0 1px var(--line); background: var(--bg) !important; font: 12.5px ui-monospace, monospace; }
.sd-brp__tr svg, .sd-brp__o svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.8; flex: none; }
.sd-brp__tr span { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-brp__p { margin-top: 6px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; animation: sd-brp-in 160ms ease-out; }
@keyframes sd-brp-in { from { opacity: 0; transform: translateY(-4px); } }
.sd-brp__p input { width: 100%; height: 38px; padding: 0 12px; border: 0; border-bottom: 1px solid var(--line); background: none; color: var(--fg); font: 12.5px ui-monospace, monospace; outline: 0; box-sizing: border-box; }
.sd-brp__tabs { display: flex; gap: 14px; padding: 0 12px; border-bottom: 1px solid var(--line); }
.sd-brp__tabs button { padding: 7px 0; font-size: 12px; color: var(--mute) !important; box-shadow: inset 0 -2px 0 transparent; }
.sd-brp__tabs button[aria-selected='true'] { color: var(--fg) !important; box-shadow: inset 0 -2px 0 var(--fg); }
.sd-brp ul { list-style: none; margin: 0; padding: 4px; max-height: 220px; overflow-y: auto; }
.sd-brp__h { padding: 6px 8px 2px; font-size: 10.5px; color: var(--mute); text-transform: uppercase; letter-spacing: 0.06em; }
.sd-brp__o { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: 7px; font: 12.5px ui-monospace, monospace; cursor: pointer; }
.sd-brp__o[data-act] { background: var(--soft); }
.sd-brp__o span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-brp__o small { font: 10.5px ui-sans-serif, system-ui; color: var(--mute); font-variant-numeric: tabular-nums; }
.sd-brp__o em { font: 600 11px ui-sans-serif, system-ui; font-style: normal; padding: 1px 6px; border-radius: 99px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-brp__o[aria-selected='true'] svg { stroke-width: 2.6; }
.sd-brp__none { padding: 16px 8px; text-align: center; font-size: 12px; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-brp *, .sd-brp *::before, .sd-brp *::after { animation: none !important; transition: none !important; } }Overview
The trigger shows the current branch. The panel filters as you type; if nothing matches, the first row offers to create the typed name from the current branch.
Each branch shows how far ahead or behind main it is, and the default branch carries a label.
When to use
- Repo views, deploy targets, preview environment pickers.
When not to use
- Choosing among two or three fixed environments: use a segmented control.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/branch-picker.json$
npx shadcn@latest add https://sundaeui.dev/r/branch-picker.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/branch-picker.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/branch-picker.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 branch-picker.tsx and branch-picker.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 { BranchPicker } from '@/components/sundae/branch-picker'
<BranchPicker />Props
| Prop | Type | Default |
|---|---|---|
branches*Branches. | { name: string; ahead: number; behind: number; recent?: boolean }[] | — |
tagsTags. | string[] | — |
onChangeCalled on switch or create. | (name: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Search is a combobox controlling a listbox; arrow keys move, Enter picks.
- Escape closes and returns focus to the trigger.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-brp; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ↑ / ↓
- Move
- Enter
- Switch or create
- Esc
- Close
FAQ
- Is Branch Picker free?
- Yes. Branch Picker is free for personal and commercial projects, with full source on this page.
- How do I install Branch Picker?
- Run npx shadcn@latest add https://sundaeui.dev/r/branch-picker.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Branch Picker support keyboard and screen readers?
- Search is a combobox controlling a listbox; arrow keys move, Enter picks. Escape closes and returns focus to the trigger.
- Does Branch Picker respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →- Pro
Wheel Picker
Forms · Refined - Pro
Date Range Presets
Forms · Refined Language Picker
Forms · RefinedTime Field
Forms · RefinedMonth Picker
Forms · RefinedCommit Composer
Forms · Refined