How To Make a Beautifully Styled Checklist/TODO List with React
Published: 2023-05-28 13:08:16

✨ [**Watch on YouTube**](https://youtu.be/kQERG9bauxY)  |  🐙 [GitHub](https://github.com/RodionChachura/reactkit)  |  🎮 [Demo](https://reactkit.radzion.com/checklist)

[Increaser](https://increaser.org) 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.

```tsx
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.

```tsx
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](/blog/hoverable). 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.

```tsx
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.

```tsx
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.

```tsx
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>
  )
}
```