Habit Streak
A habit card with this week’s days to tick off, a streak counter that rolls up and a best-streak marker.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/habit-streak.json'use client'
import { useState } from 'react'
import './habit-streak.css'
export interface HabitStreakProps {
name: string
defaultDone?: boolean[]
todayIndex: number
carried?: number
best?: number
fill?: 'dark' | 'light'
className?: string
}
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
/** Weekly habit checks with a running streak. */
export function HabitStreak({ name, defaultDone = [true, true, false, true, false, false, false], todayIndex, carried = 6, best = 14, fill = 'dark', className }: HabitStreakProps) {
const [done, setDone] = useState(defaultDone)
let streak = 0
for (let i = done[todayIndex] ? todayIndex : todayIndex - 1; i >= 0 && done[i]; i--) streak++
const allWeek = done.slice(0, done[todayIndex] ? todayIndex + 1 : todayIndex).every(Boolean)
if (allWeek) streak += carried
const count = done.slice(0, todayIndex + 1).filter(Boolean).length
return (
<div className={['sd-hbt', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-hbt__top">
<div><div className="sd-hbt__n">{name}</div><div className="sd-hbt__sub">{count} of {todayIndex + 1} days this week</div></div>
<div className="sd-hbt__streak" aria-live="polite"><b key={streak}>{streak}</b><span>day streak · best {Math.max(best, streak)}</span></div>
</div>
<div className="sd-hbt__days">
{DAYS.map((d, i) => (
<label key={d} className="sd-hbt__d" data-today={i === todayIndex || undefined}>
<span className="sd-hbt__box">
<input type="checkbox" checked={done[i]} disabled={i > todayIndex} aria-label={`${d}${i === todayIndex ? ' (today)' : ''}`} onChange={() => setDone(done.map((x, j) => (j === i ? !x : x)))} />
{done[i] && <svg viewBox="0 0 16 16" aria-hidden><path d="m3.5 8.5 3 3 6-7" /></svg>}
</span>
{d[0]}
</label>
))}
</div>
<div className="sd-hbt__foot">{done[todayIndex] ? 'Done for today.' : 'Not done yet today.'}</div>
</div>
)
}.sd-hbt { --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-hbt[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-hbt button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-hbt :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-hbt .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-hbt { width: min(100%, 320px); padding: 16px; border-radius: 22px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); font-variant-numeric: tabular-nums; }
.sd-hbt__top { display: flex; justify-content: space-between; align-items: flex-start; }
.sd-hbt__n { font-size: 15px; font-weight: 650; }
.sd-hbt__sub { font-size: 12.5px; color: var(--mute); margin-top: 2px; }
.sd-hbt__streak { text-align: right; }
.sd-hbt__streak b { display: inline-block; font-size: 28px; font-weight: 650; letter-spacing: -0.02em; line-height: 1; animation: sd-hbt-roll 320ms cubic-bezier(0.2, 1.3, 0.3, 1); }
@keyframes sd-hbt-roll { from { transform: translateY(40%); opacity: 0; } }
.sd-hbt__streak span { display: block; font-size: 11.5px; color: var(--mute); }
.sd-hbt__days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; margin-top: 16px; }
.sd-hbt__d { display: grid; justify-items: center; gap: 6px; font-size: 11px; color: var(--mute); }
.sd-hbt__d[data-today] { color: var(--fg); font-weight: 650; }
.sd-hbt__box { position: relative; display: grid; place-items: center; width: 100%; aspect-ratio: 1; max-width: 38px; border-radius: 12px; box-shadow: inset 0 0 0 1.5px var(--line); transition: background-color 200ms, box-shadow 200ms, transform 120ms; cursor: pointer; }
.sd-hbt__box:active { transform: scale(0.9); }
.sd-hbt__box input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
.sd-hbt__box input:disabled { cursor: not-allowed; }
.sd-hbt__box:has(input:checked) { background: var(--strong); box-shadow: none; color: var(--on); }
.sd-hbt__box:has(input:disabled) { opacity: 0.35; }
.sd-hbt__box:has(input:focus-visible) { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-hbt__d[data-today] .sd-hbt__box:not(:has(input:checked)) { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-hbt__box svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
.sd-hbt__box svg path { stroke-dasharray: 16; animation: sd-hbt-draw 300ms ease forwards; }
@keyframes sd-hbt-draw { from { stroke-dashoffset: 16; } to { stroke-dashoffset: 0; } }
.sd-hbt__foot { margin-top: 14px; font-size: 12.5px; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-hbt *, .sd-hbt *::before, .sd-hbt *::after { animation: none !important; transition: none !important; } }Overview
Tap today (or any past day this week) to mark it done; the check draws in and the streak number rolls. Missing a day in the middle visibly breaks the run.
Future days are disabled. The best streak sits beside the current one for motivation without confetti.
When to use
- Habit, fitness, learning and wellbeing apps.
When not to use
- Tracking quantities: use a log with numbers.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/habit-streak.json$
npx shadcn@latest add https://sundaeui.dev/r/habit-streak.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/habit-streak.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/habit-streak.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 habit-streak.tsx and habit-streak.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 { HabitStreak } from '@/components/sundae/habit-streak'
<HabitStreak />Props
| Prop | Type | Default |
|---|---|---|
name*Habit name. | string | — |
done / defaultDoneSeven booleans, Monday first. | boolean[] | — |
todayIndex*Index of today (0 = Monday). | number | — |
bestBest streak so far. | number | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Days are checkboxes labelled with the weekday and state; future days are disabled.
- The streak count is announced politely.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-hbt; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Habit Streak free?
- Yes. Habit Streak is free for personal and commercial projects, with full source on this page.
- How do I install Habit Streak?
- Run npx shadcn@latest add https://sundaeui.dev/r/habit-streak.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Habit Streak support keyboard and screen readers?
- Days are checkboxes labelled with the weekday and state; future days are disabled. The streak count is announced politely.
- Does Habit Streak respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Task List
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined Hold to Confirm
Components · Refined