Web InnoventixFreeCode

Dashboard Tasks

Original · free

tasks/checklist widget

byWeb InnoventixReact + Tailwind
dashtasksdashboards
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/dash-tasks.json
dash-tasks.tsx
"use client";

import {
  useId,
  useMemo,
  useRef,
  useState,
  type FormEvent,
  type KeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Priority = "high" | "medium" | "low";
type Filter = "all" | "active" | "done";

type Task = {
  id: string;
  title: string;
  tag: string;
  due: string;
  priority: Priority;
  assignee: string;
  initials: string;
  done: boolean;
};

const INITIAL_TASKS: Task[] = [
  {
    id: "t-1",
    title: "Ship the pricing page A/B test to 50% of traffic",
    tag: "Growth",
    due: "Today",
    priority: "high",
    assignee: "Salman Khan",
    initials: "SK",
    done: false,
  },
  {
    id: "t-2",
    title: "Trace the Lighthouse regression on /blog after the font swap",
    tag: "Perf",
    due: "Tomorrow",
    priority: "medium",
    assignee: "Ana Reyes",
    initials: "AR",
    done: false,
  },
  {
    id: "t-3",
    title: "Write migration notes for the session-auth rewrite",
    tag: "Docs",
    due: "Fri",
    priority: "low",
    assignee: "Dev Mehta",
    initials: "DM",
    done: false,
  },
  {
    id: "t-4",
    title: "Reply to the 14 Figma plugin beta testers",
    tag: "Support",
    due: "Wed",
    priority: "medium",
    assignee: "Ana Reyes",
    initials: "AR",
    done: true,
  },
  {
    id: "t-5",
    title: "Cut the 2.4.0 release branch and freeze main",
    tag: "Release",
    due: "Mon",
    priority: "high",
    assignee: "Salman Khan",
    initials: "SK",
    done: false,
  },
  {
    id: "t-6",
    title: "Delete the legacy S3 bucket now that the CDN is live",
    tag: "Infra",
    due: "Done",
    priority: "low",
    assignee: "Dev Mehta",
    initials: "DM",
    done: true,
  },
];

const PRIORITY_BADGE: Record<Priority, string> = {
  high: "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300",
  medium:
    "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300",
  low: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-300",
};

const PRIORITY_BAR: Record<Priority, string> = {
  high: "bg-rose-500",
  medium: "bg-amber-500",
  low: "bg-sky-500",
};

const AVATAR_TONE: Record<string, string> = {
  SK: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300",
  AR: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
  DM: "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
  NEW: "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
};

const FILTERS: { id: Filter; label: string }[] = [
  { id: "all", label: "All" },
  { id: "active", label: "Active" },
  { id: "done", label: "Done" },
];

const RING_RADIUS = 26;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;

export default function DashTasks() {
  const [tasks, setTasks] = useState<Task[]>(INITIAL_TASKS);
  const [filter, setFilter] = useState<Filter>("all");
  const [draft, setDraft] = useState<string>("");
  const [draftPriority, setDraftPriority] = useState<Priority>("medium");
  const [error, setError] = useState<string>("");
  const [announcement, setAnnouncement] = useState<string>("");

  const baseId = useId();
  const counter = useRef<number>(100);
  const inputRef = useRef<HTMLInputElement | null>(null);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const reduce = useReducedMotion();

  const doneCount = tasks.filter((t) => t.done).length;
  const total = tasks.length;
  const activeCount = total - doneCount;
  const pct = total === 0 ? 0 : Math.round((doneCount / total) * 100);

  const counts: Record<Filter, number> = {
    all: total,
    active: activeCount,
    done: doneCount,
  };

  const visible = useMemo<Task[]>(() => {
    if (filter === "active") return tasks.filter((t) => !t.done);
    if (filter === "done") return tasks.filter((t) => t.done);
    return tasks;
  }, [tasks, filter]);

  const toggle = (id: string) => {
    setTasks((prev) =>
      prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
    );
    const target = tasks.find((t) => t.id === id);
    if (target) {
      setAnnouncement(
        `${target.title} marked ${target.done ? "active" : "complete"}`,
      );
    }
  };

  const remove = (id: string) => {
    const target = tasks.find((t) => t.id === id);
    setTasks((prev) => prev.filter((t) => t.id !== id));
    if (target) setAnnouncement(`${target.title} deleted`);
  };

  const clearCompleted = () => {
    if (doneCount === 0) return;
    setTasks((prev) => prev.filter((t) => !t.done));
    setAnnouncement(
      `Cleared ${doneCount} completed ${doneCount === 1 ? "task" : "tasks"}`,
    );
  };

  const addTask = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const title = draft.trim();
    if (title.length === 0) {
      setError("Give the task a name before adding it.");
      inputRef.current?.focus();
      return;
    }
    counter.current += 1;
    const task: Task = {
      id: `t-${counter.current}`,
      title,
      tag: "Inbox",
      due: "Today",
      priority: draftPriority,
      assignee: "Unassigned",
      initials: "NEW",
      done: false,
    };
    setTasks((prev) => [task, ...prev]);
    setDraft("");
    setError("");
    setAnnouncement(`${title} added to the list`);
    inputRef.current?.focus();
  };

  const onTabKeyDown = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
    const last = FILTERS.length - 1;
    let target = -1;
    if (e.key === "ArrowRight") target = i === last ? 0 : i + 1;
    else if (e.key === "ArrowLeft") target = i === 0 ? last : i - 1;
    else if (e.key === "Home") target = 0;
    else if (e.key === "End") target = last;
    if (target >= 0) {
      e.preventDefault();
      setFilter(FILTERS[target].id);
      tabRefs.current[target]?.focus();
    }
  };

  const panelId = `${baseId}-panel`;
  const emptyCopy: Record<Filter, string> = {
    all: "Nothing on the board. Add the first task above.",
    active: "No open tasks left — the sprint board is clear.",
    done: "Nothing completed yet. Tick something off to see it here.",
  };

  return (
    <section className="relative w-full bg-slate-50 px-4 py-20 sm:px-6 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes dshtsk-pop {
          0% { transform: scale(0.4); opacity: 0; }
          60% { transform: scale(1.12); opacity: 1; }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes dshtsk-sheen {
          from { opacity: 0.35; }
          to { opacity: 0.7; }
        }
        .dshtsk-check { animation: dshtsk-pop 240ms cubic-bezier(0.34, 1.56, 0.64, 1) both; }
        .dshtsk-ring { transition: stroke-dashoffset 520ms cubic-bezier(0.22, 1, 0.36, 1); }
        .dshtsk-glow { animation: dshtsk-sheen 4s ease-in-out infinite alternate; }
        .dshtsk-title {
          background-image: linear-gradient(currentColor, currentColor);
          background-repeat: no-repeat;
          background-position: 0 58%;
          background-size: 0% 1.5px;
          transition: background-size 300ms ease, color 200ms ease;
        }
        .dshtsk-title[data-done="true"] { background-size: 100% 1.5px; }
        @media (prefers-reduced-motion: reduce) {
          .dshtsk-check { animation: none !important; }
          .dshtsk-ring { transition: none !important; }
          .dshtsk-glow { animation: none !important; }
          .dshtsk-title { transition: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-2xl">
        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          {/* Header */}
          <div className="relative flex items-start justify-between gap-6 border-b border-slate-200 px-5 py-5 sm:px-6 dark:border-slate-800">
            <div
              aria-hidden="true"
              className="dshtsk-glow pointer-events-none absolute -top-24 -left-16 h-48 w-48 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/15"
            />
            <div className="relative">
              <span className="inline-flex items-center gap-1.5 rounded-full bg-indigo-100 px-2.5 py-1 text-[11px] font-semibold tracking-wide text-indigo-700 uppercase dark:bg-indigo-500/15 dark:text-indigo-300">
                <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
                Sprint 24
              </span>
              <h2 className="mt-2.5 text-lg font-semibold tracking-tight text-slate-900 sm:text-xl dark:text-white">
                Team checklist
              </h2>
              <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
                {activeCount} open · {doneCount} done · ends Friday 6pm
              </p>
            </div>

            <div className="relative shrink-0">
              <svg
                width="68"
                height="68"
                viewBox="0 0 68 68"
                aria-hidden="true"
                className="-rotate-90"
              >
                <circle
                  cx="34"
                  cy="34"
                  r={RING_RADIUS}
                  fill="none"
                  strokeWidth="6"
                  className="stroke-slate-200 dark:stroke-slate-800"
                />
                <circle
                  cx="34"
                  cy="34"
                  r={RING_RADIUS}
                  fill="none"
                  strokeWidth="6"
                  strokeLinecap="round"
                  strokeDasharray={RING_CIRCUMFERENCE}
                  strokeDashoffset={
                    RING_CIRCUMFERENCE - (RING_CIRCUMFERENCE * pct) / 100
                  }
                  className="dshtsk-ring stroke-indigo-500 dark:stroke-indigo-400"
                />
              </svg>
              <span className="absolute inset-0 flex items-center justify-center text-sm font-semibold tabular-nums text-slate-900 dark:text-white">
                {pct}%
              </span>
              <span className="sr-only">{pct} percent of tasks complete</span>
            </div>
          </div>

          {/* Add task */}
          <form onSubmit={addTask} className="border-b border-slate-200 px-5 py-4 sm:px-6 dark:border-slate-800">
            <div className="flex flex-col gap-2 sm:flex-row">
              <div className="relative flex-1">
                <label htmlFor={`${baseId}-new`} className="sr-only">
                  New task title
                </label>
                <input
                  id={`${baseId}-new`}
                  ref={inputRef}
                  type="text"
                  value={draft}
                  onChange={(e) => {
                    setDraft(e.target.value);
                    if (error) setError("");
                  }}
                  placeholder="Add a task — e.g. audit the onboarding emails"
                  aria-invalid={error.length > 0}
                  aria-describedby={error ? `${baseId}-err` : undefined}
                  className={`w-full rounded-lg border bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 dark:bg-slate-950 dark:text-white dark:placeholder:text-slate-500 ${
                    error
                      ? "border-rose-400 dark:border-rose-500/60"
                      : "border-slate-300 dark:border-slate-700"
                  }`}
                />
              </div>

              <div className="relative">
                <label htmlFor={`${baseId}-prio`} className="sr-only">
                  Priority for the new task
                </label>
                <select
                  id={`${baseId}-prio`}
                  value={draftPriority}
                  onChange={(e) => setDraftPriority(e.target.value as Priority)}
                  className="w-full appearance-none rounded-lg border border-slate-300 bg-white py-2 pr-9 pl-3 text-sm font-medium text-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 sm:w-auto dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200"
                >
                  <option value="high">High</option>
                  <option value="medium">Medium</option>
                  <option value="low">Low</option>
                </select>
                <svg
                  aria-hidden="true"
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  className="pointer-events-none absolute top-1/2 right-3 h-3.5 w-3.5 -translate-y-1/2 text-slate-400"
                >
                  <path d="m6 9 6 6 6-6" />
                </svg>
              </div>

              <button
                type="submit"
                className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-indigo-600 px-3.5 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 active:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-400"
              >
                <svg
                  aria-hidden="true"
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2.5"
                  strokeLinecap="round"
                  className="h-4 w-4"
                >
                  <path d="M12 5v14M5 12h14" />
                </svg>
                Add
              </button>
            </div>
            {error ? (
              <p
                id={`${baseId}-err`}
                className="mt-2 text-xs font-medium text-rose-600 dark:text-rose-400"
              >
                {error}
              </p>
            ) : null}
          </form>

          {/* Filters */}
          <div className="flex items-center justify-between gap-3 border-b border-slate-200 px-5 py-3 sm:px-6 dark:border-slate-800">
            <div
              role="tablist"
              aria-label="Filter tasks"
              className="flex gap-1 rounded-lg bg-slate-100 p-1 dark:bg-slate-950"
            >
              {FILTERS.map((f, i) => {
                const selected = filter === f.id;
                return (
                  <button
                    key={f.id}
                    ref={(el) => {
                      tabRefs.current[i] = el;
                    }}
                    type="button"
                    role="tab"
                    id={`${baseId}-tab-${f.id}`}
                    aria-selected={selected}
                    aria-controls={panelId}
                    tabIndex={selected ? 0 : -1}
                    onClick={() => setFilter(f.id)}
                    onKeyDown={(e) => onTabKeyDown(e, i)}
                    className={`relative rounded-md px-3 py-1.5 text-xs font-semibold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 ${
                      selected
                        ? "bg-white text-slate-900 shadow-sm dark:bg-slate-800 dark:text-white"
                        : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
                    }`}
                  >
                    {f.label}
                    <span className="ml-1.5 tabular-nums opacity-60">
                      {counts[f.id]}
                    </span>
                  </button>
                );
              })}
            </div>

            <button
              type="button"
              onClick={clearCompleted}
              disabled={doneCount === 0}
              className="rounded-md px-2 py-1.5 text-xs font-semibold text-slate-500 transition-colors hover:text-rose-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-slate-500 dark:text-slate-400 dark:hover:text-rose-400 dark:disabled:hover:text-slate-400"
            >
              Clear done
            </button>
          </div>

          {/* List */}
          <div
            id={panelId}
            role="tabpanel"
            aria-labelledby={`${baseId}-tab-${filter}`}
            className="px-3 py-3 sm:px-4"
          >
            {visible.length === 0 ? (
              <p className="px-3 py-10 text-center text-sm text-slate-500 dark:text-slate-400">
                {emptyCopy[filter]}
              </p>
            ) : (
              <ul className="flex flex-col gap-1.5">
                <AnimatePresence initial={false}>
                  {visible.map((t) => {
                    const titleId = `${baseId}-title-${t.id}`;
                    return (
                      <motion.li
                        key={t.id}
                        layout={!reduce}
                        initial={reduce ? false : { opacity: 0, y: -8 }}
                        animate={{ opacity: 1, y: 0 }}
                        exit={reduce ? { opacity: 0 } : { opacity: 0, x: 14 }}
                        transition={{
                          duration: reduce ? 0 : 0.22,
                          ease: "easeOut",
                        }}
                        className="group relative flex items-start gap-3 overflow-hidden rounded-xl border border-slate-200 bg-white p-3 transition-colors hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700"
                      >
                        <span
                          aria-hidden="true"
                          className={`absolute top-2 bottom-2 left-0 w-[3px] rounded-r-full transition-opacity ${
                            PRIORITY_BAR[t.priority]
                          } ${t.done ? "opacity-25" : "opacity-100"}`}
                        />

                        <label className="flex flex-1 cursor-pointer items-start gap-3 pl-2">
                          <input
                            type="checkbox"
                            checked={t.done}
                            onChange={() => toggle(t.id)}
                            aria-labelledby={titleId}
                            className="peer sr-only"
                          />
                          <span
                            aria-hidden="true"
                            className={`mt-0.5 flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-md border-2 transition-colors peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-indigo-500 ${
                              t.done
                                ? "border-emerald-500 bg-emerald-500 text-white"
                                : "border-slate-300 bg-white text-transparent group-hover:border-indigo-400 dark:border-slate-600 dark:bg-slate-950"
                            }`}
                          >
                            {t.done ? (
                              <svg
                                viewBox="0 0 24 24"
                                fill="none"
                                stroke="currentColor"
                                strokeWidth="3.5"
                                strokeLinecap="round"
                                strokeLinejoin="round"
                                className="dshtsk-check h-3 w-3"
                              >
                                <path d="M20 6 9 17l-5-5" />
                              </svg>
                            ) : null}
                          </span>

                          <span className="min-w-0 flex-1">
                            <span
                              id={titleId}
                              data-done={t.done}
                              className={`dshtsk-title inline text-sm leading-snug font-medium ${
                                t.done
                                  ? "text-slate-400 dark:text-slate-500"
                                  : "text-slate-900 dark:text-slate-100"
                              }`}
                            >
                              {t.title}
                            </span>

                            <span className="mt-2 flex flex-wrap items-center gap-1.5">
                              <span
                                className={`inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-semibold tracking-wide uppercase ${PRIORITY_BADGE[t.priority]}`}
                              >
                                {t.priority}
                              </span>
                              <span className="inline-flex items-center rounded-md border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[10px] font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
                                {t.tag}
                              </span>
                              <span className="inline-flex items-center gap-1 text-[11px] font-medium text-slate-500 dark:text-slate-400">
                                <svg
                                  aria-hidden="true"
                                  viewBox="0 0 24 24"
                                  fill="none"
                                  stroke="currentColor"
                                  strokeWidth="2"
                                  strokeLinecap="round"
                                  className="h-3 w-3"
                                >
                                  <circle cx="12" cy="12" r="9" />
                                  <path d="M12 7v5l3 2" />
                                </svg>
                                {t.due}
                              </span>
                              <span
                                className={`ml-auto inline-flex h-5 items-center rounded-full px-1.5 text-[10px] font-bold tracking-wide ${
                                  AVATAR_TONE[t.initials] ?? AVATAR_TONE.NEW
                                }`}
                              >
                                <span aria-hidden="true">{t.initials}</span>
                                <span className="sr-only">
                                  Assigned to {t.assignee}
                                </span>
                              </span>
                            </span>
                          </span>
                        </label>

                        <button
                          type="button"
                          onClick={() => remove(t.id)}
                          aria-label={`Delete task: ${t.title}`}
                          className="mt-0.5 shrink-0 rounded-md p-1 text-slate-400 opacity-0 transition-all hover:bg-rose-50 hover:text-rose-600 focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 group-hover:opacity-100 dark:hover:bg-rose-500/10 dark:hover:text-rose-400"
                        >
                          <svg
                            aria-hidden="true"
                            viewBox="0 0 24 24"
                            fill="none"
                            stroke="currentColor"
                            strokeWidth="2"
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            className="h-4 w-4"
                          >
                            <path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" />
                            <path d="M10 11v5M14 11v5" />
                          </svg>
                        </button>
                      </motion.li>
                    );
                  })}
                </AnimatePresence>
              </ul>
            )}
          </div>

          {/* Footer */}
          <div className="flex items-center justify-between gap-3 border-t border-slate-200 px-5 py-3 text-xs text-slate-500 sm:px-6 dark:border-slate-800 dark:text-slate-400">
            <span>
              Showing{" "}
              <span className="font-semibold text-slate-700 tabular-nums dark:text-slate-200">
                {visible.length}
              </span>{" "}
              of {total}
            </span>
            <span className="inline-flex items-center gap-1.5">
              <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
              Synced 2 min ago
            </span>
          </div>
        </div>

        <p aria-live="polite" className="sr-only">
          {announcement}
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →