# 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.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/habits-1).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/habits-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/habits-1-light-5cf7d7f415e547dd.png
- Registry item: https://ui.ballmac.com/r/habits-1.json
- Categories: blocks, habits
- Listed: 2026-10-18, source last changed 2026-10-18

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/ballmac/habits-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://ui.ballmac.com/r/button.json`, `https://ui.ballmac.com/r/input.json`, `https://ui.ballmac.com/r/skeleton.json`, `https://ui.ballmac.com/r/state-view.json`

## Files

### `registry/ballmac/components/blocks/habits-1/habits-1.tsx`

```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 }
```
