How To Make a Beautifully Styled Checklist/TODO List with React

May 28, 2023

6 min read

How To Make a Beautifully Styled Checklist/TODO List with React
Watch on YouTube

Increaser has this beautiful checklist for today's tasks, and believe it or not, the first version took me just an hour or so to implement. That's the power of abstract reusable components. Let me share its implementation, so you can find useful bits to finish your front-end tasks faster.

import type { NextPage } from "next"
import { DemoPage } from "components/DemoPage"
import { useState } from "react"
import { VStack } from "lib/ui/Stack"
import { ChecklistItem } from "lib/ui/checklist/ChecklistItem"
import { Opener } from "lib/ui/Opener"
import { AddChecklistItemPrompt } from "lib/ui/checklist/AddChecklistItemPrompt"
import { ChecklistItemForm } from "lib/ui/checklist/ChecklistItemForm"
import { updateAtIndex } from "lib/shared/utils/updateAtIndex"

interface Task {
  name: string
  isComplete: boolean
}

const defaultTasks: Task[] = [
  { name: "Go to the gym", isComplete: false },
  { name: "Buy groceries", isComplete: false },
  { name: "Walk the dog", isComplete: false },
]

const ChecklistPage: NextPage = () => {
  const [tasks, setTasks] = useState(defaultTasks)

  return (
    <DemoPage title="Checklist">
      <VStack gap={16}>
        {tasks.map(({ name, isComplete }, index) => (
          <ChecklistItem
            key={index}
            value={isComplete}
            onChange={(value) => {
              setTasks(
                updateAtIndex(tasks, index, (task) => ({
                  ...task,
                  isComplete: value,
                }))
              )
            }}
            name={name}
          />
        ))}
        <Opener
          renderOpener={({ isOpen, onOpen }) =>
            isOpen ? null : (
              <AddChecklistItemPrompt onClick={onOpen}>
                Add task
              </AddChecklistItemPrompt>
            )
          }
          renderContent={({ onClose }) => (
            <ChecklistItemForm
              namePlaceholder="Enter task name"
              onSubmit={({ name }) => {
                setTasks([...tasks, { name, isComplete: false }])
                onClose()
              }}
              onCancel={onClose}
            />
          )}
        />
      </VStack>
    </DemoPage>
  )
}

export default ChecklistPage

In this demo, we have a list of tasks in the useState hook. We map through the items and render a ChecklistItem for each task. We also render an Opener component that renders a button to add a new task. When the button is clicked, the Opener renders a ChecklistItemForm component to add a new task.

import { ReactNode } from "react"
import styled, { css } from "styled-components"
import { Hoverable } from "../Hoverable"
import { defaultTransitionCSS } from "../animations/transitions"
import { CheckIcon } from "../icons/CheckIcon"
import {
  InvisibleHTMLCheckboxProps,
  InvisibleHTMLCheckbox,
} from "../inputs/Checkbox/InvisibleHTMLCheckbox"
import { centerContentCSS } from "../utils/centerContentCSS"
import { Text } from "../Text"
import { ChecklistItemFrame } from "./ChecklistItemFrame"

interface ChecklistItemProps extends InvisibleHTMLCheckboxProps {
  name: ReactNode
  style?: React.CSSProperties
}

export const Box = styled.div<{ isChecked: boolean }>`
  width: 100%;
  aspect-ratio: 1/1;

  ${centerContentCSS};

  border-radius: 4px;
  border: 2px solid ${({ theme }) => theme.colors.textSupporting3.toCssValue()};
  color: ${({ theme }) => theme.colors.background.toCssValue()};

  ${defaultTransitionCSS}

  ${({ isChecked }) =>
    isChecked &&
    css`
      background: ${({ theme }) => theme.colors.primary.toCssValue()};
      border-color: ${({ theme }) => theme.colors.primary.toCssValue()};
    `};
`

const Content = styled(Text)<{ isChecked: boolean }>`
  max-width: 100%;
  position: relative;
  color: ${({ theme, isChecked }) =>
    (isChecked ? theme.colors.textSupporting : theme.colors.text).toCssValue()};
`

const Line = styled.span<{ isChecked: boolean }>`
  position: absolute;
  ${defaultTransitionCSS};
  left: 0;
  border-top: 2px solid;
  bottom: 10px;
  width: ${({ isChecked }) => (isChecked ? "100%" : "0%")};
`

export const ChecklistItem = ({
  value,
  onChange,
  name,
  style,
}: ChecklistItemProps) => {
  return (
    <Hoverable style={style} as="label">
      <ChecklistItemFrame>
        <Box isChecked={value}>{value && <CheckIcon />}</Box>
        <Content isChecked={value} cropped>
          {name}
          <Line isChecked={value} />
        </Content>
        <InvisibleHTMLCheckbox value={value} onChange={onChange} />
      </ChecklistItemFrame>
    </Hoverable>
  )
}

Let's start with the ChecklistItem component. It receives the same properties as the InvisibleHTMLCheckbox component, but also a name property that is rendered as a label and a style property that is passed to the root element. The component renders a Hoverable component as a label. You can read more about the Hoverable component in this article. Inside we place a ChecklistItemFrame that serve as a frame we'll use for both a prompt to add a task, and the form to add a task. By using the same component to define shape of an interactive area we make the interface more consistent and therefore more intuitive and pleasing to the user.

import styled from "styled-components"

export const ChecklistItemFrame = styled.div`
  display: grid;
  width: 100%;
  grid-template-columns: 24px 1fr;
  align-items: center;
  justify-items: start;
  gap: 12px;
  font-weight: 500;
`

To make the Box component square we use combination of a 100% width and aspect-ratio of one to one. When the isChecked property is true, we change the background and border color to the primary color. We also render a CheckIcon component when the isChecked property is true.

Next goes the Content component that renders the name of the task. We also use a Line component to animate the line crossing the name when the task is completed. We use the isChecked property to animate the line from zero to full width.

Finally, we render the InvisibleHTMLCheckbox component that is used to toggle the isChecked property. Notice we have a label element as a root element, so we don't have to add onClick anywhere, and solely rely on native checkbox input to do its job even so it's invisible.

To start the flow of adding a new task we have AddChecklistItemPrompt component. It receives the onClick and children properties. Here we also use the Hoverable and ChecklistItemFrame components to define the shape of the interactive area.

import {
  ClickableComponentProps,
  ComponentWithChildrenProps,
} from "lib/shared/props"
import { Center } from "../Center"
import { Hoverable } from "../Hoverable"
import { ChecklistItemFrame } from "./ChecklistItemFrame"
import { PlusIcon } from "../icons/PlusIcon"

type AddChecklistItemPromptProps = ClickableComponentProps &
  ComponentWithChildrenProps

export const AddChecklistItemPrompt = ({
  onClick,
  children,
}: AddChecklistItemPromptProps) => {
  return (
    <Hoverable onClick={onClick}>
      <ChecklistItemFrame>
        <Center>
          <PlusIcon />
        </Center>
        {children}
      </ChecklistItemFrame>
    </Hoverable>
  )
}

Finally we have the ChecklistItemForm component that receives the onSubmit and onCancel callbacks togehter with the namePlaceholder property that is used as a placeholder for the name input. We use the useForm hook from the react-hook-form library to handle the form state. We also use the useKey hook from the react-use library to handle the Escape key press to cancel the form. Here we also leverage the ChecklistItemFrame component to make the shape similar to the other items in the checklist.

import { useForm } from "react-hook-form"
import { useKey } from "react-use"
import styled from "styled-components"
import { Box } from "./ChecklistItem"
import { ChecklistItemFrame } from "./ChecklistItemFrame"

interface ChecklistItemFormShape {
  name: string
}

const Input = styled.input`
  background: transparent;
  border: none;
  height: 100%;
  width: 100%;
  color: ${({ theme }) => theme.colors.text.toCssValue()};
  outline: none;

  &::placeholder {
    color: ${({ theme }) => theme.colors.textSupporting3.toCssValue()};
  }
`

interface ChecklistItemFormProps {
  onSubmit: (value: ChecklistItemFormShape) => void
  onCancel: () => void
  namePlaceholder?: string
}

export const ChecklistItemForm = ({
  onSubmit,
  onCancel,
  namePlaceholder = "Name",
}: ChecklistItemFormProps) => {
  const { register, handleSubmit } = useForm<ChecklistItemFormShape>({
    mode: "all",
    defaultValues: {
      name: "",
    },
  })

  useKey("Escape", onCancel)

  return (
    <ChecklistItemFrame
      as="form"
      style={{ width: "100%" }}
      onBlur={handleSubmit(onSubmit, onCancel)}
      onSubmit={handleSubmit(onSubmit)}
    >
      <Box isChecked={false} />
      <Input
        placeholder={namePlaceholder}
        autoFocus
        {...register("name", { required: true })}
      />
    </ChecklistItemFrame>
  )
}