Habit Tracker Phone Screen with Tab Bar

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.

Code

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

When to use it

How to customize it

Accessibility

Questions

Is this a real mobile app?

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.

How is the progress ring drawn?

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.

How do I save the ticks between visits?

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.

How tall should a bottom tab bar be?

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.

Can I make it a light theme?

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.

More in App UI

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.

Product

Who It's For

Features

Resources

Company

View Sitemap