Habits 1: streaks and weekly goals
A habit tracker: each habit with its streak (kept alive until today ends), best streak, this week against its goal, the last seven days to tick, twelve weeks as a grid, a ring for today, and ticks that go back if they fail.
The normal view. Pick a state to see what the block shows while it loads, is empty or fails.
// Ballmac UI: Habits 1. https://ui.ballmac.com/blocks/habits-1
"use client"
import * as React from "react"
import { Check, CircleAlert, Flame, Plus, Sprout, Trophy } from "lucide-react"
import { Button } from "@/components/ballmac/button"
import { Input } from "@/components/ballmac/input"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"
type Habits1Habit = {
id: string
name: string
/** How many days a week is the goal. */
target: number
/** ISO dates it was done. */
done: string[]
/** One of the five chart colors, 1 to 5. */
tone?: 1 | 2 | 3 | 4 | 5
}
type Habits1Props = Omit<React.ComponentProps<"section">, "title"> &
StateProps & {
habits?: Habits1Habit[]
/** An ISO date for "today". The block never reads the clock. */
today?: string
title?: string
locale?: string
/** Called when a day is ticked or unticked. Return a promise; reject and the day goes back with a message. */
onToggle?: (habitId: string, date: string, done: boolean) => void | Promise<void>
onAdd?: (habit: { name: string; target: number }) => void | Promise<void>
}
const DAY = 86_400_000
const toT = (iso: string) => Date.parse(`${iso}T00:00:00Z`)
const iso = (t: number) => new Date(t).toISOString().slice(0, 10)
// A believable history without any randomness, so the server and the browser draw the same grid.
const hist = (today: string, every: number, offset: number, upTo: number): string[] => Array.from({ length: 84 }, (_, i) => i).filter((i) => i >= upTo && (i * 7 + offset) % every !== 0 && (i * 3 + offset) % 5 !== 0).map((i) => iso(toT(today) - i * DAY))
const sample = (today: string): Habits1Habit[] => [
{ id: "h1", name: "Read for 20 minutes", target: 5, done: hist(today, 9, 2, 0), tone: 1 },
{ id: "h2", name: "Walk 8,000 steps", target: 6, done: hist(today, 7, 1, 1), tone: 2 },
{ id: "h3", name: "Write three lines", target: 7, done: hist(today, 4, 3, 2), tone: 3 },
{ id: "h4", name: "No screens after 22:00", target: 5, done: hist(today, 6, 0, 0).filter((_, k) => k % 2 === 0 || k > 30), tone: 4 },
]
const tones = ["", "bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4", "bg-chart-5"]
function Skel() {
return (
<div aria-hidden="true" className="grid gap-4">
<Skeleton className="h-28 rounded-2xl" />
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-44 rounded-2xl" />
))}
</div>
)
}
// A streak is the run of days ending today, or ending yesterday if today is not done yet, so it is not lost until the day is over.
function streakOf(done: Set<string>, today: string) {
let t = toT(today)
if (!done.has(iso(t))) t -= DAY
let n = 0
while (done.has(iso(t))) { n++; t -= DAY }
return n
}
function bestOf(done: Set<string>) {
const days = [...done].map(toT).sort((a, b) => a - b)
let best = 0
let run = 0
days.forEach((d, i) => { run = i && d - days[i - 1]! === DAY ? run + 1 : 1; best = Math.max(best, run) })
return best
}
/**
* A habit tracker: each habit shows its streak (kept alive until today is over), its best streak, this week against its goal, the last seven days to tick or untick,
* and twelve weeks as a grid. The ring at the top counts what is done today. A tick is counted at once and goes back, with the reason, if it cannot be saved.
*/
function Habits1({ habits: habitsProp, today = "2026-10-18", title = "Habits", locale = "en", onToggle, onAdd, state = "ready", onRetry, emptyState, errorState, className, ...props }: Habits1Props) {
const id = React.useId()
const [habits, setHabits] = React.useState<Habits1Habit[]>(() => habitsProp ?? sample(today))
React.useEffect(() => { if (habitsProp) setHabits(habitsProp) }, [habitsProp])
const list = state === "empty" ? [] : habits
const [error, setError] = React.useState<{ id: string; text: string } | null>(null)
const [name, setName] = React.useState("")
const [target, setTarget] = React.useState(5)
const [announce, setAnnounce] = React.useState("")
const dayFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "narrow", timeZone: "UTC" }), [locale])
const longFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" }), [locale])
const last7 = Array.from({ length: 7 }, (_, i) => iso(toT(today) - (6 - i) * DAY))
// Weeks run Monday to Sunday; "this week" is the Monday on or before today up to today.
const dow = (new Date(toT(today)).getUTCDay() + 6) % 7
const weekDays = Array.from({ length: dow + 1 }, (_, i) => iso(toT(today) - i * DAY))
const doneToday = list.filter((h) => h.done.includes(today)).length
async function toggle(h: Habits1Habit, date: string) {
const on = !h.done.includes(date)
const before = habits
setError(null)
setHabits((hs) => hs.map((x) => (x.id === h.id ? { ...x, done: on ? [...x.done, date] : x.done.filter((d) => d !== date) } : x)))
setAnnounce(`${h.name}, ${longFmt.format(toT(date))}: ${on ? "done" : "not done"}`)
try {
await onToggle?.(h.id, date, on)
} catch (e) {
setHabits(before)
setError({ id: h.id, text: e instanceof Error ? e.message : `That change was not saved, so ${longFmt.format(toT(date))} went back.` })
}
}
async function add(e: React.FormEvent) {
e.preventDefault()
const n = name.trim()
if (!n) return
const h: Habits1Habit = { id: `n${habits.length}${n.length}`, name: n, target, done: [], tone: ((habits.length % 5) + 1) as 1 | 2 | 3 | 4 | 5 }
setHabits((hs) => [...hs, h])
setName("")
try { await onAdd?.({ name: n, target }) } catch { setHabits((hs) => hs.filter((x) => x.id !== h.id)); setError({ id: "new", text: "The habit could not be added, so it was taken out again." }) }
}
const content = !list.length ? (
<div className="bg-card rounded-2xl border px-6 py-16 text-center">
<span aria-hidden="true" className="bg-muted mx-auto flex size-12 items-center justify-center rounded-full">
<Sprout className="size-6" />
</span>
<h2 className="mt-5 text-lg font-semibold tracking-tight">{emptyState?.title ?? "No habits yet"}</h2>
<p className="text-muted-foreground mx-auto mt-1.5 max-w-sm text-sm text-pretty">{emptyState?.description ?? "Pick one small thing to do most days. A streak starts with the first tick."}</p>
{emptyState?.action}
</div>
) : (
<div className="grid gap-5">
<div className="bg-card flex flex-wrap items-center gap-6 rounded-3xl border p-5 sm:p-7">
<div className="relative">
<svg aria-hidden="true" viewBox="0 0 100 100" className="size-24 -rotate-90 rtl:rotate-90">
<circle cx="50" cy="50" r="42" fill="none" strokeWidth="9" className="stroke-muted" />
<circle cx="50" cy="50" r="42" fill="none" strokeWidth="9" strokeLinecap="round" strokeDasharray={2 * Math.PI * 42} strokeDashoffset={2 * Math.PI * 42 * (1 - doneToday / list.length)} className="stroke-foreground transition-[stroke-dashoffset] duration-500 motion-reduce:transition-none" />
</svg>
<span className="absolute inset-0 flex items-center justify-center text-xl font-semibold tabular-nums">
{doneToday}/{list.length}
</span>
</div>
<div>
<p className="text-muted-foreground text-sm">{longFmt.format(toT(today))}</p>
<p className="text-xl font-semibold tracking-tight text-balance">{doneToday === list.length ? "Every habit done today" : doneToday ? `${list.length - doneToday} to go today` : "Nothing ticked yet today"}</p>
</div>
</div>
<ul className="grid gap-4">
{list.map((h) => {
const set = new Set(h.done)
const streak = streakOf(set, today)
const best = Math.max(bestOf(set), streak)
const week = weekDays.filter((d) => set.has(d)).length
const hit = week >= h.target
const dot = tones[h.tone ?? 1]
return (
<li key={h.id} className="bg-card rounded-2xl border p-5 sm:p-6">
<article aria-labelledby={`${id}-${h.id}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 id={`${id}-${h.id}`} className="flex items-center gap-2.5 text-lg font-semibold tracking-tight">
<span aria-hidden="true" className={cn("size-3 shrink-0 rounded-full", dot)} />
{h.name}
</h2>
<p className="text-muted-foreground mt-1 text-sm tabular-nums">
{week} of {h.target} this week{hit ? ", goal reached" : ""}
</p>
</div>
<div className="flex items-center gap-4 text-sm tabular-nums">
<p className="flex items-center gap-1.5 font-semibold">
<Flame aria-hidden="true" className={cn("size-5", streak ? "text-orange-500" : "text-muted-foreground")} />
{streak} {streak === 1 ? "day" : "days"}
<span className="sr-only"> streak</span>
</p>
<p className="text-muted-foreground flex items-center gap-1.5">
<Trophy aria-hidden="true" className="size-4" />
Best {best}
</p>
</div>
</div>
<ol aria-label={`Last seven days of ${h.name}`} className="mt-5 grid grid-cols-7 gap-2">
{last7.map((d) => {
const on = set.has(d)
return (
<li key={d} className="text-center">
<span aria-hidden="true" className="text-muted-foreground mb-1.5 block text-xs">
{dayFmt.format(toT(d))}
</span>
<button type="button" role="checkbox" aria-checked={on} aria-label={`${h.name}, ${longFmt.format(toT(d))}${d === today ? ", today" : ""}`} onClick={() => void toggle(h, d)} className={cn("focus-visible:ring-ring/60 mx-auto flex size-10 items-center justify-center rounded-full border-2 outline-none transition-[transform,background-color] focus-visible:ring-[3px] active:scale-90 motion-reduce:transition-none sm:size-11", on ? cn("border-transparent text-white", dot) : "hover:bg-accent", d === today && !on && "border-foreground")}>
{on ? <Check aria-hidden="true" className="size-5" /> : null}
</button>
</li>
)
})}
</ol>
<div dir="ltr" role="img" aria-label={`Twelve weeks: done on ${h.done.filter((d) => toT(today) - toT(d) < 84 * DAY && toT(d) <= toT(today)).length} of the last 84 days`} className="mt-5 grid w-fit grid-flow-col grid-rows-[repeat(7,0.875rem)] auto-cols-[0.875rem] gap-[3px]">
{Array.from({ length: 84 }, (_, i) => {
const col = Math.floor(i / 7)
const row = i % 7
// Oldest on the left; the last column is the current week with its days from Monday.
const dayIndex = (11 - col) * 7 + (dow - row)
const d = iso(toT(today) - dayIndex * DAY)
const future = dayIndex < 0
return <span key={i} className={cn("size-3.5 rounded-[3px]", future ? "bg-transparent" : set.has(d) ? dot : "bg-muted")} />
})}
</div>
{error?.id === h.id ? (
<p role="alert" className="text-destructive mt-3 flex items-start gap-2 text-sm">
<CircleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
{error.text}
</p>
) : null}
</article>
</li>
)
})}
</ul>
<form onSubmit={add} className="bg-muted/30 flex flex-wrap items-end gap-3 rounded-2xl border border-dashed p-4 sm:p-5">
<div className="min-w-48 flex-1">
<label htmlFor={`${id}-n`} className="text-muted-foreground mb-1.5 block text-xs font-medium">New habit</label>
<Input id={`${id}-n`} value={name} onChange={(e) => setName(e.target.value)} placeholder="Stretch for ten minutes" />
</div>
<div>
<label htmlFor={`${id}-t`} className="text-muted-foreground mb-1.5 block text-xs font-medium">Days a week</label>
<select id={`${id}-t`} value={target} onChange={(e) => setTarget(Number(e.target.value))} className="bg-background focus-visible:ring-ring/50 h-9 rounded-md border px-3 text-sm outline-none focus-visible:ring-[3px]">
{[1, 2, 3, 4, 5, 6, 7].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
<Button type="submit" variant="outline" disabled={!name.trim()}>
<Plus aria-hidden="true" />
Add habit
</Button>
{error?.id === "new" ? (
<p role="alert" className="text-destructive w-full text-sm">
{error.text}
</p>
) : null}
</form>
</div>
)
return (
<section data-slot="habits-1" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-3xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}>
<h1 id={`${id}-title`} className="mb-6 text-3xl font-semibold tracking-[-0.035em] text-balance">
{title}
</h1>
<StateView state={state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading your habits" loading={<Skel />} error={{ title: errorState?.title ?? "Your habits could not be loaded", description: errorState?.description ?? "Nothing was lost. Check your connection and try again.", ...errorState }}>
{content}
</StateView>
<p aria-live="polite" className="sr-only">
{announce}
</p>
</section>
)
}
export { Habits1, type Habits1Props, type Habits1Habit }Installation
$ pnpm dlx shadcn@latest add @ballmac/habits-1Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/button @ballmac/input @ballmac/skeleton @ballmac/state-viewCopy the source into your project.
components/ballmac/blocks/habits-1/habits-1.tsx// Ballmac UI: Habits 1. https://ui.ballmac.com/blocks/habits-1 "use client" import * as React from "react" import { Check, CircleAlert, Flame, Plus, Sprout, Trophy } from "lucide-react" import { Button } from "@/components/ballmac/button" import { Input } from "@/components/ballmac/input" import { Skeleton } from "@/components/ballmac/skeleton" import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view" import { cn } from "@/lib/utils" type Habits1Habit = { id: string name: string /** How many days a week is the goal. */ target: number /** ISO dates it was done. */ done: string[] /** One of the five chart colors, 1 to 5. */ tone?: 1 | 2 | 3 | 4 | 5 } type Habits1Props = Omit<React.ComponentProps<"section">, "title"> & StateProps & { habits?: Habits1Habit[] /** An ISO date for "today". The block never reads the clock. */ today?: string title?: string locale?: string /** Called when a day is ticked or unticked. Return a promise; reject and the day goes back with a message. */ onToggle?: (habitId: string, date: string, done: boolean) => void | Promise<void> onAdd?: (habit: { name: string; target: number }) => void | Promise<void> } const DAY = 86_400_000 const toT = (iso: string) => Date.parse(`${iso}T00:00:00Z`) const iso = (t: number) => new Date(t).toISOString().slice(0, 10) // A believable history without any randomness, so the server and the browser draw the same grid. const hist = (today: string, every: number, offset: number, upTo: number): string[] => Array.from({ length: 84 }, (_, i) => i).filter((i) => i >= upTo && (i * 7 + offset) % every !== 0 && (i * 3 + offset) % 5 !== 0).map((i) => iso(toT(today) - i * DAY)) const sample = (today: string): Habits1Habit[] => [ { id: "h1", name: "Read for 20 minutes", target: 5, done: hist(today, 9, 2, 0), tone: 1 }, { id: "h2", name: "Walk 8,000 steps", target: 6, done: hist(today, 7, 1, 1), tone: 2 }, { id: "h3", name: "Write three lines", target: 7, done: hist(today, 4, 3, 2), tone: 3 }, { id: "h4", name: "No screens after 22:00", target: 5, done: hist(today, 6, 0, 0).filter((_, k) => k % 2 === 0 || k > 30), tone: 4 }, ] const tones = ["", "bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4", "bg-chart-5"] function Skel() { return ( <div aria-hidden="true" className="grid gap-4"> <Skeleton className="h-28 rounded-2xl" /> {[0, 1, 2].map((i) => ( <Skeleton key={i} className="h-44 rounded-2xl" /> ))} </div> ) } // A streak is the run of days ending today, or ending yesterday if today is not done yet, so it is not lost until the day is over. function streakOf(done: Set<string>, today: string) { let t = toT(today) if (!done.has(iso(t))) t -= DAY let n = 0 while (done.has(iso(t))) { n++; t -= DAY } return n } function bestOf(done: Set<string>) { const days = [...done].map(toT).sort((a, b) => a - b) let best = 0 let run = 0 days.forEach((d, i) => { run = i && d - days[i - 1]! === DAY ? run + 1 : 1; best = Math.max(best, run) }) return best } /** * A habit tracker: each habit shows its streak (kept alive until today is over), its best streak, this week against its goal, the last seven days to tick or untick, * and twelve weeks as a grid. The ring at the top counts what is done today. A tick is counted at once and goes back, with the reason, if it cannot be saved. */ function Habits1({ habits: habitsProp, today = "2026-10-18", title = "Habits", locale = "en", onToggle, onAdd, state = "ready", onRetry, emptyState, errorState, className, ...props }: Habits1Props) { const id = React.useId() const [habits, setHabits] = React.useState<Habits1Habit[]>(() => habitsProp ?? sample(today)) React.useEffect(() => { if (habitsProp) setHabits(habitsProp) }, [habitsProp]) const list = state === "empty" ? [] : habits const [error, setError] = React.useState<{ id: string; text: string } | null>(null) const [name, setName] = React.useState("") const [target, setTarget] = React.useState(5) const [announce, setAnnounce] = React.useState("") const dayFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "narrow", timeZone: "UTC" }), [locale]) const longFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" }), [locale]) const last7 = Array.from({ length: 7 }, (_, i) => iso(toT(today) - (6 - i) * DAY)) // Weeks run Monday to Sunday; "this week" is the Monday on or before today up to today. const dow = (new Date(toT(today)).getUTCDay() + 6) % 7 const weekDays = Array.from({ length: dow + 1 }, (_, i) => iso(toT(today) - i * DAY)) const doneToday = list.filter((h) => h.done.includes(today)).length async function toggle(h: Habits1Habit, date: string) { const on = !h.done.includes(date) const before = habits setError(null) setHabits((hs) => hs.map((x) => (x.id === h.id ? { ...x, done: on ? [...x.done, date] : x.done.filter((d) => d !== date) } : x))) setAnnounce(`${h.name}, ${longFmt.format(toT(date))}: ${on ? "done" : "not done"}`) try { await onToggle?.(h.id, date, on) } catch (e) { setHabits(before) setError({ id: h.id, text: e instanceof Error ? e.message : `That change was not saved, so ${longFmt.format(toT(date))} went back.` }) } } async function add(e: React.FormEvent) { e.preventDefault() const n = name.trim() if (!n) return const h: Habits1Habit = { id: `n${habits.length}${n.length}`, name: n, target, done: [], tone: ((habits.length % 5) + 1) as 1 | 2 | 3 | 4 | 5 } setHabits((hs) => [...hs, h]) setName("") try { await onAdd?.({ name: n, target }) } catch { setHabits((hs) => hs.filter((x) => x.id !== h.id)); setError({ id: "new", text: "The habit could not be added, so it was taken out again." }) } } const content = !list.length ? ( <div className="bg-card rounded-2xl border px-6 py-16 text-center"> <span aria-hidden="true" className="bg-muted mx-auto flex size-12 items-center justify-center rounded-full"> <Sprout className="size-6" /> </span> <h2 className="mt-5 text-lg font-semibold tracking-tight">{emptyState?.title ?? "No habits yet"}</h2> <p className="text-muted-foreground mx-auto mt-1.5 max-w-sm text-sm text-pretty">{emptyState?.description ?? "Pick one small thing to do most days. A streak starts with the first tick."}</p> {emptyState?.action} </div> ) : ( <div className="grid gap-5"> <div className="bg-card flex flex-wrap items-center gap-6 rounded-3xl border p-5 sm:p-7"> <div className="relative"> <svg aria-hidden="true" viewBox="0 0 100 100" className="size-24 -rotate-90 rtl:rotate-90"> <circle cx="50" cy="50" r="42" fill="none" strokeWidth="9" className="stroke-muted" /> <circle cx="50" cy="50" r="42" fill="none" strokeWidth="9" strokeLinecap="round" strokeDasharray={2 * Math.PI * 42} strokeDashoffset={2 * Math.PI * 42 * (1 - doneToday / list.length)} className="stroke-foreground transition-[stroke-dashoffset] duration-500 motion-reduce:transition-none" /> </svg> <span className="absolute inset-0 flex items-center justify-center text-xl font-semibold tabular-nums"> {doneToday}/{list.length} </span> </div> <div> <p className="text-muted-foreground text-sm">{longFmt.format(toT(today))}</p> <p className="text-xl font-semibold tracking-tight text-balance">{doneToday === list.length ? "Every habit done today" : doneToday ? `${list.length - doneToday} to go today` : "Nothing ticked yet today"}</p> </div> </div> <ul className="grid gap-4"> {list.map((h) => { const set = new Set(h.done) const streak = streakOf(set, today) const best = Math.max(bestOf(set), streak) const week = weekDays.filter((d) => set.has(d)).length const hit = week >= h.target const dot = tones[h.tone ?? 1] return ( <li key={h.id} className="bg-card rounded-2xl border p-5 sm:p-6"> <article aria-labelledby={`${id}-${h.id}`}> <div className="flex flex-wrap items-start justify-between gap-3"> <div className="min-w-0"> <h2 id={`${id}-${h.id}`} className="flex items-center gap-2.5 text-lg font-semibold tracking-tight"> <span aria-hidden="true" className={cn("size-3 shrink-0 rounded-full", dot)} /> {h.name} </h2> <p className="text-muted-foreground mt-1 text-sm tabular-nums"> {week} of {h.target} this week{hit ? ", goal reached" : ""} </p> </div> <div className="flex items-center gap-4 text-sm tabular-nums"> <p className="flex items-center gap-1.5 font-semibold"> <Flame aria-hidden="true" className={cn("size-5", streak ? "text-orange-500" : "text-muted-foreground")} /> {streak} {streak === 1 ? "day" : "days"} <span className="sr-only"> streak</span> </p> <p className="text-muted-foreground flex items-center gap-1.5"> <Trophy aria-hidden="true" className="size-4" /> Best {best} </p> </div> </div> <ol aria-label={`Last seven days of ${h.name}`} className="mt-5 grid grid-cols-7 gap-2"> {last7.map((d) => { const on = set.has(d) return ( <li key={d} className="text-center"> <span aria-hidden="true" className="text-muted-foreground mb-1.5 block text-xs"> {dayFmt.format(toT(d))} </span> <button type="button" role="checkbox" aria-checked={on} aria-label={`${h.name}, ${longFmt.format(toT(d))}${d === today ? ", today" : ""}`} onClick={() => void toggle(h, d)} className={cn("focus-visible:ring-ring/60 mx-auto flex size-10 items-center justify-center rounded-full border-2 outline-none transition-[transform,background-color] focus-visible:ring-[3px] active:scale-90 motion-reduce:transition-none sm:size-11", on ? cn("border-transparent text-white", dot) : "hover:bg-accent", d === today && !on && "border-foreground")}> {on ? <Check aria-hidden="true" className="size-5" /> : null} </button> </li> ) })} </ol> <div dir="ltr" role="img" aria-label={`Twelve weeks: done on ${h.done.filter((d) => toT(today) - toT(d) < 84 * DAY && toT(d) <= toT(today)).length} of the last 84 days`} className="mt-5 grid w-fit grid-flow-col grid-rows-[repeat(7,0.875rem)] auto-cols-[0.875rem] gap-[3px]"> {Array.from({ length: 84 }, (_, i) => { const col = Math.floor(i / 7) const row = i % 7 // Oldest on the left; the last column is the current week with its days from Monday. const dayIndex = (11 - col) * 7 + (dow - row) const d = iso(toT(today) - dayIndex * DAY) const future = dayIndex < 0 return <span key={i} className={cn("size-3.5 rounded-[3px]", future ? "bg-transparent" : set.has(d) ? dot : "bg-muted")} /> })} </div> {error?.id === h.id ? ( <p role="alert" className="text-destructive mt-3 flex items-start gap-2 text-sm"> <CircleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" /> {error.text} </p> ) : null} </article> </li> ) })} </ul> <form onSubmit={add} className="bg-muted/30 flex flex-wrap items-end gap-3 rounded-2xl border border-dashed p-4 sm:p-5"> <div className="min-w-48 flex-1"> <label htmlFor={`${id}-n`} className="text-muted-foreground mb-1.5 block text-xs font-medium">New habit</label> <Input id={`${id}-n`} value={name} onChange={(e) => setName(e.target.value)} placeholder="Stretch for ten minutes" /> </div> <div> <label htmlFor={`${id}-t`} className="text-muted-foreground mb-1.5 block text-xs font-medium">Days a week</label> <select id={`${id}-t`} value={target} onChange={(e) => setTarget(Number(e.target.value))} className="bg-background focus-visible:ring-ring/50 h-9 rounded-md border px-3 text-sm outline-none focus-visible:ring-[3px]"> {[1, 2, 3, 4, 5, 6, 7].map((n) => ( <option key={n} value={n}> {n} </option> ))} </select> </div> <Button type="submit" variant="outline" disabled={!name.trim()}> <Plus aria-hidden="true" /> Add habit </Button> {error?.id === "new" ? ( <p role="alert" className="text-destructive w-full text-sm"> {error.text} </p> ) : null} </form> </div> ) return ( <section data-slot="habits-1" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-3xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}> <h1 id={`${id}-title`} className="mb-6 text-3xl font-semibold tracking-[-0.035em] text-balance"> {title} </h1> <StateView state={state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading your habits" loading={<Skel />} error={{ title: errorState?.title ?? "Your habits could not be loaded", description: errorState?.description ?? "Nothing was lost. Check your connection and try again.", ...errorState }}> {content} </StateView> <p aria-live="polite" className="sr-only"> {announce} </p> </section> ) } export { Habits1, type Habits1Props, type Habits1Habit }Update the import paths to match your project setup.
Usage
"use client"
import { Habits1 } from "@/components/ballmac/blocks/habits-1/habits-1"
export default function Habits1Demo() {
return <Habits1 onToggle={() => new Promise((r) => setTimeout(r, 400))} />
}States
Pass state and the block does the rest: skeletons that match its layout, a clear empty message, an error you can retry, and announcements for screen readers.
Loading
Skeleton cards under the real heading, announced as loading.
state="loading"No habits yet
Says to pick one small thing to do most days; emptyState sets the copy and can add a button.
state="empty" (or habits={[]})Could not load
An alert saying nothing was lost, with Try again (onRetry). A tick that fails to save goes back with its reason under the habit.
state="error"
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 5 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Not applicable: Right to left
Not mirrored by design: The twelve-week history runs oldest to newest from left to right in every language, like a chart, and makes up most of the page's elements.
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
47 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Passed: Loading, empty and error
Ships loading, empty, error states, each with an example.
Passed: Automated tests
Covered by habits-1.test.tsx.
For information: Footprint
4.9 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
- npm
- lucide-react
Use with AI
A habit tracker. <Habits1 habits={[{ id, name, target: 5, done: ['2026-10-18'], tone: 1..5 }]} today='2026-10-18' locale onToggle={(habitId, date, done) => Promise} onAdd={({ name, target }) => Promise} />. The streak is the run of days ending today, or yesterday while today is not done yet, so it only breaks when a day is missed. Best streak, this-week count (Monday to today) and the goal are worked out from done. Pass today yourself; the block never reads the clock. Reject onToggle and the day goes back with the message. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Habits 1: streaks and weekly goals (@ballmac/habits-1) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Wellness, learning and productivity apps with daily routines
- A team ritual tracker
Not for
- One-off to-dos (use tasks-1)
- Long-term targets with numbers (use goals-1)
Registry JSON: https://ui.ballmac.com/r/habits-1.json
Free to use in personal and commercial projects.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.