Skip to content

The Art of the Pure CSS Checkbox: A Deep Dive into Transition Effects

In modern web development, we often reach for JavaScript to create interactive UI elements. But what if you could create a beautiful, animated, and fully functional to-do list checkbox using only HTML and CSS? It’s not only possible, but it’s also a fantastic way to master some of the most powerful concepts in CSS.

In this article, we'll deconstruct a stylish to-do list example, dive deep into the core CSS principles that make it work, and finish with a practical challenge to test your new skills.

Part 1: Deconstructing the Todo List Example

Let's start by examining the simple yet effective code from our example.

The HTML Structure (todo.html)

The HTML is intentionally minimal. The key is its structure, where the <input>, the custom checkmark <i>, and the text <span> are all siblings within a <label>.

html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Todo List</title>
    <link rel="stylesheet" href="todo.css">
</head>
<body>
    <form>
      <fieldset class="todo-list">
        <legend class="todo-list__title">My Special Todo List</legend>
        <label class="todo-list__label">
          <input type="checkbox" name="" id="" />
          <i class="check"></i>
          <span>Make awesome CSS animation</span>
        </label>
        <!-- ... more labels ... -->
      </fieldset>
    </form>
</body>
</html>

Why it works:

  • <label>: Wrapping everything in a <label> makes the entire area, including the text, clickable to toggle the checkbox.
  • Sibling Relationship: The <input>, <i>, and <span> are all on the same level. This is crucial for our CSS selectors to work.

The CSS Magic (todo.css)

Here is the fully commented CSS, explaining the role of each block.

css
@import url("https://fonts.googleapis.com/css?family=Lato:400,400i,700");

/* Basic setup for the body to center the content */
body {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  background: #1a1e23;
}

/* The main container for the todo list */
.todo-list {
  display: flex;
  flex-direction: column;
  padding: 0 75px 10px 30px;
  background: #162740;
  border: transparent;
}

/* The title of the todo list */
.todo-list .todo-list__title {
  padding: 3px 6px;
  color: #f1faee;
  background-color: #264456;
}

/* Each individual todo item is a label, which makes the whole area clickable */
.todo-list .todo-list__label {
  /* By making the parent relative, we create a containing block for our
     absolutely positioned custom checkbox. This is best practice. */
  position: relative;
  display: flex;
  align-items: center;
  margin: 40px 0;
  font-size: 24px;
  font-family: Lato, sans-serif;
  color: #f1faee;
  cursor: pointer;
}

/* This is the "state machine" - the actual checkbox. We make it invisible
   but still use its :checked state to control the styles of its siblings. */
.todo-list .todo-list__label input[type="checkbox"] {
  opacity: 0; /* Make it invisible */
  -webkit-appearance: none; /* Remove default browser styling */
  -moz-appearance: none;
  appearance: none;
}

/* This is our custom-styled checkbox. It's the "first brother" or adjacent sibling
   to the hidden input. */
.todo-list .todo-list__label input[type="checkbox"] + .check {
  /* position: absolute takes this element out of the normal document flow.
     This allows us to place it precisely on top of the original (now invisible)
     checkbox without it being pushed aside by other elements. */
  position: absolute;
  width: 25px;
  height: 25px;
  border: 2px solid #f1faee;
  transition: 0.2s; /* Animate changes to this element */
}

/* When the hidden checkbox is CHECKED, we use the sibling selector (+)
   to change the style of our custom checkbox, turning it into a checkmark. */
.todo-list .todo-list__label input[type="checkbox"]:checked + .check {
  width: 25px;
  height: 15px;
  border-top: transparent;
  border-right: transparent;
  transform: rotate(-45deg); /* Rotate the box to form a checkmark */
}

/* This is the text of the todo item, the "second brother" to the hidden input. */
.todo-list .todo-list__label input[type="checkbox"] ~ span {
  position: relative; /* Needed for the ::before pseudo-element to be positioned correctly */
  left: 40px; /* Push the text to the right to make space for the custom checkbox */
  white-space: nowrap;
  transition: 0.5s;
}

/* This is the strikethrough line. It's a pseudo-element attached to the text span.
   By default, it's invisible because its width is scaled to 0. */
.todo-list .todo-list__label input[type="checkbox"] ~ span::before {
  position: absolute;
  content: "";
  top: 50%;
  left: 0;
  width: 100%;
  height: 1px;
  background: #f1faee;
  transform: scaleX(0); /* Make the line invisible by default */
  transform-origin: right; /* Set the animation origin to the right side */
  transition: transform 0.5s; /* Animate the transform property */
}

/* When the hidden checkbox is CHECKED, change the text color to be dimmer. */
.todo-list .todo-list__label input[type="checkbox"]:checked ~ span {
  color: #585b57;
}

/* When the hidden checkbox is CHECKED, make the strikethrough line visible
   and change its animation origin to the left. */
.todo-list .todo-list__label input[type="checkbox"]:checked ~ span::before {
  transform: scaleX(1); /* Scale the line to its full width */
  transform-origin: left; /* Animate from the left, creating the "wipe" effect */
}

Part 2: Deep Dive into the Core Concepts

The magic of this example comes from the clever combination of several fundamental CSS features.

1. The Hidden State Machine

The <input type="checkbox"> is the heart of the operation. It's a native HTML element that holds a binary state: checked or unchecked. By making it invisible (opacity: 0), we can use its state to drive our animations without having to look at the browser's default checkbox.

2. Sibling Selectors: The Glue

Since we can't style elements based on their children's state (without the modern :has() selector), we place our interactive elements as siblings to the hidden input. This lets us use:

  • + (Adjacent Sibling): To target the element immediately following the input (our custom .check box).
  • ~ (General Sibling): To target any sibling that follows the input (our text <span>). The selector input:checked ~ span literally means: "When an input is checked, find any span sibling that follows it and apply these styles."

3. Pseudo-elements: The Visual Workhorses

::before and ::after allow us to create elements with CSS that don't exist in the HTML. This keeps our markup clean. Here, span::before is used to create the strikethrough line. Because it's generated by CSS, we can style and animate it independently.

4. The Animation Trio: transform, transition, and transform-origin

  • transform: scaleX(value): This is the core of the animation. scaleX(0) squashes the element to have zero width, making it invisible. scaleX(1) gives it its full width.
  • transition: This property tells the browser to smoothly animate a change between two states instead of making it instantaneous. transition: transform 0.5s creates the half-second wipe effect.
  • transform-origin: This is the secret to the directional wipe. It sets the anchor point for the transform.
    • On Check: We set transform-origin: left. The line grows from scaleX(0) to scaleX(1) starting from the left, appearing to wipe rightwards.
    • On Uncheck: The style reverts to the default transform-origin: right. The line shrinks from scaleX(1) to scaleX(0) towards the right, appearing to wipe leftwards.

5. The Containing Block and Positioning

When you use position: absolute on an element (like our custom .check box), it is positioned relative to its nearest positioned ancestor. A positioned ancestor is a parent with any position value other than static (e.g., relative, absolute, fixed).

By adding position: relative to the parent .todo-list__label, we establish it as the containing block. This ensures that our .check element is positioned relative to the label, not the <body> or some other element, making our component self-contained and predictable.

6. Pseudo-elements as "Children"

For styling and positioning, a pseudo-element like span::before behaves as if it were a child of the span. This is why setting position: relative on the span allows us to use position: absolute on its ::before pseudo-element to place the strikethrough line precisely within the boundaries of the text.

Part 3: Homework - The Typora Theme Challenge

Now it's your turn to apply these concepts in a real-world scenario.

The Challenge: Implement the exact same "wipe" transition effect for the to-do list inside a Typora theme.

The Twist: Typora doesn't use the :checked pseudo-class on the input. Instead, when a task is completed, it adds a class named .task-list-done to the parent <li> element. You will need to adapt your selectors to this new structure.

The Goal:

  • When a task is checked, the text should turn gray and a strikethrough line should wipe across it from left to right.
  • When a task is unchecked, the text color should return to normal and the strikethrough should wipe away from right to left.

Solution for Assessment

Here is a complete, working solution for the five.css file in Typora.

css
/**
 * Typora Theme Solution for Animated Task Lists
 *
 * This block replaces the default task list styles.
 */

/** 1. Style the custom checkbox visuals (optional, but good practice) **/
.md-task-list-item > input {
  appearance: none; /* Hide the default checkbox */
  -webkit-appearance: none;
}

/* Style the empty box for an unchecked item */
.md-task-list-item > input::before {
  content: "";
  display: inline-block;
  width: 1.1rem;
  height: 1.1rem;
  vertical-align: middle;
  text-align: center;
  color: white;
  border-radius: 1px;
  border: 2px solid var(--task-border-color);
  transition: all 0.2s linear;
}

/* Transform the box into a checkmark when checked */
.md-task-list-item > input:checked::before {
  content: "";
  display: inline-block;
  width: 1.1rem;
  height: 0.6rem;
  border: 2px solid var(--task-focus-color);
  border-top: transparent;
  border-right: transparent;
  transform: rotate(-45deg);
}

/* Ensure the hidden input doesn't disrupt layout */
#write input[type="checkbox"] {
  position: absolute;
  top: -6px;
  left: -3px;
  background-color: inherit;
}

/** 2. The Core Animation Logic **/

/* The text of the task item */
li.md-task-list-item > span {
  position: relative; /* Establishes the containing block for the strikethrough */
  transition: color 0.5s; /* Animate the text color change */
}

/* The strikethrough line pseudo-element */
li.md-task-list-item > span::before {
  position: absolute;
  content: "";
  top: 50%;
  left: 0;
  width: 100%;
  height: 1px;
  background: #999;
  transform: scaleX(0); /* Hidden by default */
  transform-origin: right; /* Default wipe-away direction is to the right */
  transition: transform 0.5s; /* Animate the wipe */
}

/* When the PARENT li has the .task-list-done class... */
li.md-task-list-item.task-list-done > span {
  color: #999; /* ...make the text gray. */
}

li.md-task-list-item.task-list-done > span::before {
  transform: scaleX(1); /* ...make the strikethrough visible. */
  transform-origin: left; /* ...and animate it from the left. */
}
最近更新