A habit tracker drawn inside a phone frame, with a status bar, a week strip, a progress ring, a list of habits and a bottom tab bar. Tapping a habit ticks it off and the ring, the summary line and the weekly chart all update. The three tabs really switch: Today shows the list, Week shows a bar chart and the longest streaks, and Me shows a profile with two switches. The app is dark with one lime accent. The frame is 340 pixels wide at most and shrinks to fit narrow screens.
import { useState } from "react";
const startingHabits = [
{ id: "water", name: "Drink 2 litres of water", detail: "6 of 8 glasses", streak: 12, done: false },
{ id: "stretch", name: "Morning stretch", detail: "10 minutes", streak: 5, done: true },
{ id: "read", name: "Read 20 pages", detail: "Before bed", streak: 23, done: false },
{ id: "walk", name: "Walk 8,000 steps", detail: "6,240 so far", streak: 3, done: true },
{ id: "journal", name: "Write three lines", detail: "Journal", streak: 15, done: true },
];
// Share of habits finished on each earlier day of the week, from 0 to 1. Today is worked out from the list above.
const week = [
{ day: "Mon", date: 5, score: 1 },
{ day: "Tue", date: 6, score: 0.8 },
{ day: "Wed", date: 7, score: 0.6 },
{ day: "Thu", date: 8, score: 1 },
{ day: "Fri", date: 9, score: 0, today: true },
{ day: "Sat", date: 10, score: 0 },
{ day: "Sun", date: 11, score: 0 },
];
const tabs = [
{ key: "today", label: "Today", icon: "M4 6.5h12v10H4zM4 9.5h12M7 4v3M13 4v3" },
{ key: "week", label: "Week", icon: "M4.5 16v-5M10 16V4M15.5 16V8" },
{ key: "me", label: "Me", icon: "M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM4 16.5c.6-2.6 3-4 6-4s5.4 1.4 6 4" },
];
const RING = 2 * Math.PI * 26;
export default function HabitTrackerPhoneScreenWithTabBar() {
const [tab, setTab] = useState("today");
const [habits, setHabits] = useState(startingHabits);
const [reminders, setReminders] = useState(true);
const [restDays, setRestDays] = useState(false);
const done = habits.filter((h) => h.done).length;
const share = done / habits.length;
const toggle = (id: string) => setHabits((list) => list.map((h) => (h.id === id ? { ...h, done: !h.done } : h)));
const focus = "focus:outline-none focus-visible:ring-2 focus-visible:ring-lime-300";
const settings = [
{ label: "Daily reminder", note: "Every day at 8:00 pm", on: reminders, set: setReminders },
{ label: "Weekend rest days", note: "Streaks pause on Sat and Sun", on: restDays, set: setRestDays },
];
return (
<div className="bg-zinc-100 px-2.5 py-10 text-white">
<div className="mx-auto w-full max-w-[340px] rounded-[44px] bg-zinc-800 p-2 shadow-2xl shadow-zinc-400/60 ring-1 ring-zinc-700">
<div className="relative flex h-[760px] flex-col overflow-hidden rounded-[36px] bg-zinc-950">
{/* Status bar */}
<div aria-hidden="true" className="relative flex h-11 flex-none items-center justify-between px-7 text-xs font-semibold">
<span>9:41</span>
<span className="absolute left-1/2 top-2.5 h-6 w-24 -translate-x-1/2 rounded-full bg-black" />
<span className="flex items-center gap-1.5">
<svg viewBox="0 0 18 12" fill="currentColor" className="h-2.5 w-4"><rect x="0" y="8" width="3" height="4" rx="1" /><rect x="5" y="5" width="3" height="7" rx="1" /><rect x="10" y="2.5" width="3" height="9.5" rx="1" /><rect x="15" y="0" width="3" height="12" rx="1" /></svg>
<span className="flex h-2.5 w-5 items-center rounded-sm border border-white/70 p-px"><span className="h-full w-3/4 rounded-sm bg-white" /></span>
</span>
</div>
<div className="flex-1 overflow-y-auto px-5 pb-4">
{tab === "today" && (
<>
<p className="mt-3 text-sm text-zinc-400">Friday 9 October</p>
<h2 className="text-2xl font-semibold tracking-tight">Today</h2>
<ol className="mt-4 grid grid-cols-7 gap-1">
{week.map((d) => (
<li key={d.day} className={`flex flex-col items-center rounded-2xl py-2 ${d.today ? "bg-lime-300 text-zinc-950" : ""}`}>
<span className={`text-xs ${d.today ? "font-semibold" : "text-zinc-400"}`}>{d.day}</span>
<span className="mt-1 text-sm font-semibold tabular-nums">{d.date}</span>
<span className={`mt-1.5 h-1.5 w-1.5 rounded-full ${d.today ? "bg-zinc-950" : d.score === 1 ? "bg-lime-300" : d.score > 0 ? "bg-lime-300/50" : "bg-zinc-700"}`} />
</li>
))}
</ol>
<div className="mt-4 flex items-center gap-4 rounded-3xl bg-zinc-900 p-4">
<svg viewBox="0 0 64 64" aria-hidden="true" className="h-16 w-16 flex-none -rotate-90">
<circle cx="32" cy="32" r="26" fill="none" strokeWidth="7" className="stroke-zinc-800" />
<circle cx="32" cy="32" r="26" fill="none" strokeWidth="7" strokeLinecap="round" strokeDasharray={RING} strokeDashoffset={RING * (1 - share)} className="stroke-lime-300 transition-all duration-300" />
</svg>
<div aria-live="polite">
<p className="text-lg font-semibold leading-tight">{done} of {habits.length} done</p>
<p className="mt-0.5 text-sm text-zinc-400">{done === habits.length ? "Everything is ticked off. Nice work." : `${habits.length - done} to go before the day ends.`}</p>
</div>
</div>
<ul className="mt-3 space-y-2">
{habits.map((h) => (
<li key={h.id}>
<button type="button" aria-pressed={h.done} onClick={() => toggle(h.id)} className={`flex w-full items-center gap-3 rounded-2xl p-3 text-left transition ${focus} ${h.done ? "bg-zinc-900/60" : "bg-zinc-900"}`}>
<span className={`flex h-7 w-7 flex-none items-center justify-center rounded-full border-2 transition ${h.done ? "border-lime-300 bg-lime-300 text-zinc-950" : "border-zinc-600"}`}>
{h.done && (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4"><path d="m5 10.5 3.2 3.2L15 6.5" /></svg>
)}
</span>
<span className="min-w-0 flex-1">
<span className={`block truncate text-sm font-medium ${h.done ? "text-zinc-400 line-through decoration-zinc-600" : ""}`}>{h.name}</span>
<span className="block text-xs text-zinc-400">{h.detail}</span>
</span>
<span className="flex flex-none items-center gap-1 text-xs font-semibold tabular-nums text-orange-300">
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-3.5 w-3.5"><path d="M10.5 2c.7 3.2 4 4.6 4 8.5a4.5 4.5 0 0 1-9 0c0-1.6.6-2.8 1.5-3.7.3 1.2 1 1.9 1.7 2.1C8.5 6.7 9.3 4.1 10.5 2Z" /></svg>
{h.streak}<span className="sr-only"> day streak</span>
</span>
</button>
</li>
))}
</ul>
</>
)}
{tab === "week" && (
<>
<p className="mt-3 text-sm text-zinc-400">5 to 11 October</p>
<h2 className="text-2xl font-semibold tracking-tight">This week</h2>
<div className="mt-4 rounded-3xl bg-zinc-900 p-4">
<p className="text-sm text-zinc-400">Habits completed</p>
<p className="text-3xl font-semibold tabular-nums tracking-tight">{17 + done}<span className="text-base font-medium text-zinc-400"> of 35</span></p>
<div aria-hidden="true" className="mt-5 flex h-36 items-end gap-2">
{week.map((d) => {
const value = d.today ? share : d.score;
return (
<div key={d.day} className="flex h-full flex-1 flex-col items-center justify-end gap-2">
<div className="flex w-full flex-1 items-end rounded-lg bg-zinc-800">
<div className={`w-full rounded-lg transition-all duration-300 ${d.today ? "bg-lime-300" : "bg-lime-300/50"}`} style={{ height: `${Math.round(value * 100)}%` }} />
</div>
<span className={`text-xs ${d.today ? "font-semibold text-white" : "text-zinc-400"}`}>{d.day[0]}</span>
</div>
);
})}
</div>
</div>
<h3 className="mt-5 text-sm font-semibold">Longest streaks</h3>
<ul className="mt-2 divide-y divide-zinc-800 rounded-3xl bg-zinc-900 px-4">
{[...habits].sort((a, b) => b.streak - a.streak).slice(0, 4).map((h) => (
<li key={h.id} className="flex items-center justify-between gap-3 py-3 text-sm">
<span className="truncate">{h.name}</span>
<span className="flex-none font-semibold tabular-nums text-orange-300">{h.streak} days</span>
</li>
))}
</ul>
</>
)}
{tab === "me" && (
<>
<div className="mt-6 flex flex-col items-center text-center">
<span className="flex h-20 w-20 items-center justify-center rounded-full bg-lime-300 text-2xl font-semibold text-zinc-950">NH</span>
<h2 className="mt-3 text-xl font-semibold tracking-tight">Noor Haddad</h2>
<p className="text-sm text-zinc-400">Tracking since March</p>
</div>
<dl className="mt-5 grid grid-cols-2 gap-2">
{[["Best streak", "23 days"], ["Habits", `${habits.length} active`]].map(([label, value]) => (
<div key={label} className="rounded-3xl bg-zinc-900 p-4">
<dt className="text-xs text-zinc-400">{label}</dt>
<dd className="mt-1 text-lg font-semibold">{value}</dd>
</div>
))}
</dl>
<ul className="mt-2 divide-y divide-zinc-800 rounded-3xl bg-zinc-900 px-4">
{settings.map((s) => (
<li key={s.label} className="flex items-center justify-between gap-3 py-3.5">
<span>
<span className="block text-sm font-medium">{s.label}</span>
<span className="block text-xs text-zinc-400">{s.note}</span>
</span>
<button type="button" role="switch" aria-checked={s.on} aria-label={s.label} onClick={() => s.set(!s.on)} className={`flex h-10 w-14 flex-none items-center rounded-full ${focus}`}>
<span className={`flex h-7 w-12 items-center rounded-full p-0.5 transition ${s.on ? "bg-lime-300" : "bg-zinc-700"}`}>
<span className={`h-6 w-6 rounded-full transition ${s.on ? "translate-x-5 bg-zinc-950" : "bg-zinc-300"}`} />
</span>
</button>
</li>
))}
</ul>
</>
)}
</div>
{/* Bottom tab bar */}
<nav aria-label="App sections" className="flex-none border-t border-zinc-800 bg-zinc-950 px-4 pb-5 pt-2">
<ul className="grid grid-cols-3">
{tabs.map((t) => (
<li key={t.key}>
<button type="button" aria-current={tab === t.key ? "page" : undefined} onClick={() => setTab(t.key)} className={`flex h-14 w-full flex-col items-center justify-center gap-1 rounded-2xl text-xs font-medium transition ${focus} ${tab === t.key ? "text-lime-300" : "text-zinc-400 hover:text-white"}`}>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-6 w-6"><path d={t.icon} /></svg>
{t.label}
</button>
</li>
))}
</ul>
<span aria-hidden="true" className="mx-auto mt-1 block h-1 w-28 rounded-full bg-zinc-700" />
</nav>
</div>
</div>
</div>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/habit-tracker-phone-screen-with-tab-bar.json
It is a React component, so it runs anywhere React does. You can use it as a mobile web screen, or as the design reference for a native app. The phone frame is only for showing it on a web page.
It is an SVG circle with a dash as long as its circumference. The dash offset is set from the share of habits done, so the visible stroke grows as habits are ticked off. A transition class animates the change.
Replace the `habits` state with data from your backend, or write the list to `localStorage` in a `useEffect` and read it back when the component first loads.
Tall enough for a comfortable thumb tap, which is 48 pixels or more per tab, plus room for the home indicator on phones without a button. The tabs here are 56 pixels tall.
Yes. Change `bg-zinc-950` to `bg-white`, the cards from `bg-zinc-900` to `bg-zinc-100`, the main text to `text-zinc-900` and the grey text to `text-zinc-600`. Use a darker accent such as `lime-600` for the ring and ticks.
Related: Game Main Menu with Level Select, Stat Tiles with Sparkline Trends, App Shell with Sidebar and Top Bar, Button Set: Primary, Secondary, Ghost, Icon and Loading.
More app and game ui or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.