Pico CSS notes

Introduction

A minimalist and lightweight starter kit that prioritizes semantic syntax, making every HTML element responsive and elegant by default. Write HTML, Add Pico CSS, and Voilà!

picocss.com

I've used this project for prototyping and some personal projects I haven't published. It lets me start with something beautiful without the distraction of setting up a design system. Up till now, I've used it by referencing the full source via CDN, but I wanted to dig into its internals and learn how it works.

After installing npm, sass, and pico v2.1.1, I tested it with the default options:

// main.scss
@use pico;

Compiled with:

npx sass --load-path=node_modules/@picocss/pico/scss main.scss main.css

The primary way to customize pico is via CSS variables, but sass allows customization at the compilation step. This allows you to choose a theme color, compile without css classes, change the css variable prefix, and ignore modules to reduce bundle size.

Modules

The descriptive module names preview what they're used for. I wanted to see the size of each of the modules after compilation. I took the default options from the docs and disabled every module. Compiling that outputs no CSS selectors and a copyright comment on the top. This serves as the baseline size.

Next I prompted Claude Sonnet 4.6 with

  1. Enable each module one at a time
  2. run npx sass --load-path=node_modules/@picocss/pico/scss main.scss with-module-XX.css replacing XX with module name
  3. diff with previous and get the character count
  4. add a comment to final main.css with the difference in size and description of what the module does

The annotated listing is:

// main.scss
@use "pico" with (
  $theme-color: "yellow",
  $modules: (
    // Themes
    "themes/default": false,         // +8106 — breakpoints, responsive typography, and color palette

    // Layout
    "layout/document": false,       // +756  — html/body resets: box-sizing, scrollbar, font-size, line-height
    "layout/landmarks": false,      // +317  — semantic sectioning: header, main, footer, aside padding/margin
    "layout/container": false,      // +612  — .container max-width centering with responsive breakpoints
    "layout/section": false,        // +213  — section vertical padding and spacing
    "layout/grid": false,           // +454  — CSS grid helper: .grid equal-column responsive layout
    "layout/overflow-auto": false,  // +87   — overflow-auto wrapper for horizontal scroll

    // Content
    "content/link": false,          // +2144 — <a> color, underline, hover/focus styles incl. role=button
    "content/typography": false,    // +4742 — headings h1–h6, p, ul/ol/dl, blockquote, strong, em, mark, del
    "content/embedded": false,      // +441  — img, svg, video, canvas max-width and responsive sizing
    "content/button": false,        // +7304 — <button> and input[type=submit/reset/button] full styling
    "content/table": false,         // +1244 — <table>, thead, tbody, tfoot, tr, th, td, overflow-auto
    "content/code": false,          // +1603 — <code>, <pre>, <kbd>, <samp> monospace and block styles
    "content/figure": false,        // +195  — <figure> margin/padding and <figcaption> muted color
    "content/misc": false,          // +282  — <hr>, <abbr>, <dialog>, <template>, <details>/<summary>

    // Forms
    "forms/basics": true,          // +14186 — input, textarea, select, fieldset, label full form system
    "forms/checkbox-radio-switch": false, // +6240 — styled checkboxes, radio buttons, and toggle switches
    "forms/input-color": false,     // +373  — <input type=color> sizing and border styles
    "forms/input-date": false,      // +2221 — date/time/week/month input styles and calendar icon
    "forms/input-file": false,      // +896  — <input type=file> custom file picker button styles
    "forms/input-range": false,     // +3287 — <input type=range> track, thumb, and focus styles
    "forms/input-search": false,    // +1730 — <input type=search> clear button and search icon styles

    // Components
    "components/accordion": false,  // +3248 — <details>/<summary> accordion animation and chevron icon
    "components/card": false,       // +2105 — .card with padding, background, border-radius, shadow
    "components/dropdown": false,   // +7139 — <details role=list> dropdown menu with positioning and animation
    "components/group": false,      // +6565 — .group for inline button+input combos with joined borders
    "components/loading": false,    // +2559 — [aria-busy=true] spinning loader with CSS animation
    "components/modal": false,      // +3794 — <dialog> modal overlay, transitions, close button
    "components/nav": false,        // +2910 — <nav> with breadcrumb, pagination, and link list styles
    "components/progress": false,   // +1873 — <progress> bar with fill color and animation
    "components/tooltip": false,    // +5366 — [data-tooltip] attribute-based tooltip with arrow

    // Utilities
    "utilities/accessibility": false, // +459 — .visually-hidden / [hidden] and focus-visible helpers
    "utilities/reduce-motion": false  // +490 — prefers-reduced-motion: disables transitions and animations
  )
);

The docs mention a lightweight version without classes and uncommon components is about ~50% smaller. I compared the full with light and came up with:

$ wc -c full.css light.css 
 91913 full.css
 48315 light.css

Minimal reset

Resets are grouped with their respective components. The document level resets are pretty minimal.

// scss/layout/_document.scss
@use "sass:map";
@use "../settings" as *;

@if map.get($modules, "layout/document") {
  /**
   * Document
   * Content-box & Responsive typography
   */

  // Reboot based on :
  // - normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css
  // - sanitize.css v13.0.0 | CC0 1.0 Universal | github.com/csstools/sanitize.css
  // ––––––––––––––––––––

  // 1. Add border box sizing in all browsers (opinionated)
  // 2. Backgrounds do not repeat by default (opinionated)
  *,
  *::before,
  *::after {
    box-sizing: border-box; // 1
    background-repeat: no-repeat; // 2
  }

  // 1. Add text decoration inheritance in all browsers (opinionated)
  // 2. Add vertical alignment inheritance in all browsers (opinionated)
  ::before,
  ::after {
    text-decoration: inherit; // 1
    vertical-align: inherit; // 2
  }

  // 1. Change the line height in all browsers (opinionated)
  // 2. Breaks words to prevent overflow in all browsers (opinionated)
  // 3. Use a 4-space tab width in all browsers (opinionated)
  // 4. Remove the grey highlight on links in iOS (opinionated)
  // 5. Prevent adjustments of font size after orientation changes in iOS
  :where(:root),
  :where(:host) {
    -webkit-tap-highlight-color: transparent; // 4
    -webkit-text-size-adjust: 100%; // 5
    text-size-adjust: 100%; // 5
    background-color: var(#{$css-var-prefix}background-color);
    color: var(#{$css-var-prefix}color);
    font-weight: var(#{$css-var-prefix}font-weight);
    font-size: var(#{$css-var-prefix}font-size);
    line-height: var(#{$css-var-prefix}line-height); // 1
    font-family: var(#{$css-var-prefix}font-family);
    text-underline-offset: var(#{$css-var-prefix}text-underline-offset);
    text-rendering: optimizeLegibility;
    overflow-wrap: break-word; // 2
    tab-size: 4; // 3
  }
}

Customization with CSS variables

By using CSS variables for customization, the applied styles are evaluated when the element is about to be rendered. This simplifies reasoning about which rule is being applied. This also makes the source files agnostic to file load order during compilation. It's ok for components to depend on variables that haven't been defined yet because they aren't used until "runtime" (at the time of render).

When I browsed components through the web inspector, I found the cascade levels to be shallow and easy to reason about.

Low specificity defaults

:where is used to set defaults with zero specificity. This means any other style definition (element, id, class, inline) would override it.

An example for links:

/*
// matches
<a>Link</a>
<button role="link">Something</button>

// doesn't match
<a role="button"></a>
*/
:where(a:not([role=button])),
[role=link] {
  --pico-color: var(--pico-primary);
  --pico-background-color: transparent;
  --pico-underline: var(--pico-primary-underline);
  /* ... */
}

0 specificity :where(:root) is also used for document level resets shown in the listing above.

Responsiveness

There are px defined breakpoints and typography variables are scaled for readability. I like this default for prose, but find it looks cartoonish when interfaces with more forms and app components. The breakpoints can be customized, and there are default ratios defined for font scaling. The same is true for spacing.

Icons

There are a handful of svg icons directly embedded into the CSS. A magnifying glass for search, a normalized downward caret for dropdowns for example. I like how these are inlined to avoid additional setup for assets, and also makes them cached alongside the CSS.

Conclusion

What draws me to this project is being able to write semantic HTML with beautiful defaults. Reading the source also gives me an appreciation for how to customize and extend pico.