Lightning CSS

PlaygroundDocsRust docsnpmGitHub

Custom transforms

The Lightning CSS visitor API can be used to implement custom transform plugins in JavaScript. It is designed to enable custom non-standard extensions to CSS, making your code easier to author while shipping standard CSS to the browser. You can implement extensions such as custom shorthand properties or additional at-rules (e.g. mixins), build time transforms (e.g. convert units, inline constants, etc.), CSS rule analysis, and much more.

Custom transforms have a build time cost: it can be around 2x slower to compile with a JS visitor than without. This means visitors should generally be used to implement custom, non-standard CSS extensions. Common standard transforms such as compiling modern standard CSS features (and draft specs) for older browsers should be done in Rust as part of Lightning CSS itself. Please open an issue if there's a feature we don't handle yet.

Visitors

Custom transforms are implemented by passing a visitor object to the Lightning CSS Node API. A visitor includes one or more functions which are called for specific value types such as Rule, Property, or Length. In general, you should try to be as specific as possible about the types of values you want to handle. This way, Lightning CSS needs to call into JS as infrequently as possible, with the smallest objects possible, which improves performance. See the TypeScript definitions for a full list of available visitor functions.

Visitors can return a new value to update it. Each visitor accepts a different type of value, and usually expects the same type in return. This example multiplies all lengths by 2:

import { transform } from 'lightningcss';

let res = transform({
  filename: 'test.css',
  minify: true,
  code: Buffer.from(`
    .foo {
      width: 12px;
    }
  `),
  visitor: {
    Length(length) {
      return {
        unit: length.unit,
        value: length.value * 2
      }
    }
  }
});

assert.equal(res.code.toString(), '.foo{width:24px}');

Some visitor functions accept an array as a return value, enabling you to replace one value with multiple, or remove a value by returning an empty array. You can also provide an object instead of a function to further reduce the number of times a visitor is called. For example, when providing a Property visitor, you can use an object with keys for specific property names. This improves performance by only calling your visitor function when needed.

This example adds -webkit-overflow-scrolling: touch before any overflow properties.

let res = transform({
  filename: 'test.css',
  minify: true,
  code: Buffer.from(`
    .foo {
      overflow: auto;
    }
  `),
  visitor: {
    Property: {
      overflow(property) {
        return [{
          property: 'custom',
          value: {
            name: '-webkit-overflow-scrolling',
            value: [{
              type: 'token',
              value: {
                type: 'ident',
                value: 'touch'
              }
            }]
          }
        }, property];
      },
    }
  }
});

assert.equal(res.code.toString(), '.foo{-webkit-overflow-scrolling:touch;overflow:auto}');

Value types

The Lightning CSS AST is very detailed – each CSS property has a specific value type with all parts fully normalized. For example, a shorthand property such as background includes values for all of its sub-properties such as background-color, background-image, background-position, etc. This makes it both easier and faster for custom transforms to correctly handle all value types without reimplementing parsing. See the TypeScript definitions for full documentation of all values.

Known property values can be either parsed or unparsed. Parsed values are fully expanded following the CSS specification. Unparsed values could not be parsed according to the grammar, and are stored as raw CSS tokens. This may occur because the value is invalid, or because it included unknown values such as CSS variables. Each property visitor function will need to handle both types of values.

transform({
  code: Buffer.from(`
    .foo { width: 12px }
    .bar { width: var(--w) }
  `),
  visitor: {
    Property: {
      width(v) {
        if (v.property === 'unparsed') {
          // Handle unparsed value, e.g. `var(--w)`
        } else {
          // Handle parsed value, e.g. `12px`
        }
      }
    }
  }
});

Unknown properties, including custom properties, have the property type "custom". These values are also stored as raw CSS tokens. To visit custom properties, use the custom visitor function, or an object to filter by name. For example, to handle a custom size property and expand it to width and height, the following transform might be used.

let res = transform({
  minify: true,
  code: Buffer.from(`
    .foo {
      size: 12px;
    }
  `),
  visitor: {
    Property: {
      custom: {
        size(property) {
          // Handle the size property when the value is a length.
          if (property.value[0].type === 'length') {
            let value = {
              type: 'length-percentage',
              value: { type: 'dimension', value: property.value[0].value }
            };

            return [
              { property: 'width', value },
              { property: 'height', value }
            ];
          }
        }
      }
    }
  }
});

assert.equal(res.code.toString(), '.foo{width:12px;height:12px}');

Raw values

The Lightning CSS AST is very detailed, which is really useful when you need to transform it. However, it can be tedious to construct a full AST from scratch when returning entirely new values from a visitor. That's when raw values come in handy. You can return a raw property containing a string of CSS syntax from visitors that return declarations (i.e. properties) and tokens, and Lightning CSS will parse it for you and put it into the AST.

This example implements a custom color function, which returns a raw CSS color value as a string, rather than constructing the whole AST.

let res = transform({
  minify: true,
  code: Buffer.from(`
    .foo {
      color: color('red');
    }
  `),
  visitor: {
    Function: {
      color() {
        return { raw: 'rgb(255, 0, 0)' };
      }
    }
  }
});

assert.equal(res.code.toString(), '.foo{color:red}');

Entry and exit visitors

By default, visitors are called when traversing downward through the tree (a pre-order traversal). This means each node is visited before its children. Sometimes it is useful to process a node after its children instead (a post-order traversal). This can be done by using an Exit visitor function, such as FunctionExit.

For example, if you had a function visitor to double a length argument, and a visitor to replace an environment variable with a value, you could use an exit visitor to process the function after its arguments.

let res = transform({
  filename: 'test.css',
  minify: true,
  code: Buffer.from(`
    .foo {
      padding: double(env(--branding-padding));
    }
  `),
  visitor: {
    FunctionExit: {
      // This will run after the EnvironmentVariable visitor, below.
      double(f) {
        if (f.arguments[0].type === 'length') {
          return {
            type: 'length',
            value: {
              unit: f.arguments[0].value.unit,
              value: f.arguments[0].value.value * 2
            }
          };
        }
      }
    },
    EnvironmentVariable: {
      // This will run before the FunctionExit visitor, above.
      '--branding-padding': () => ({
        type: 'length',
        value: {
          unit: 'px',
          value: 20
        }
      })
    }
  }
});

assert.equal(res.code.toString(), '.foo{padding:40px}');

Composing visitors

Multiple visitors can be combined into one using the composeVisitors function. This lets you reuse visitors between projects by publishing them as plugins. The AST is visited in a single pass, running the functions from each visitor object as if they were written together.

import { transform, composeVisitors } from 'lightningcss';

let environmentVisitor = {
  EnvironmentVariable: {
    '--branding-padding': () => ({
      type: 'length',
      value: {
        unit: 'px',
        value: 20
      }
    })
  }
};

let doubleFunctionVisitor = {
  FunctionExit: {
    double(f) {
      if (f.arguments[0].type === 'length') {
        return {
          type: 'length',
          value: {
            unit: f.arguments[0].value.unit,
            value: f.arguments[0].value.value * 2
          }
        };
      }
    }
  }
};

let res = transform({
  filename: 'test.css',
  minify: true,
  code: Buffer.from(`
    .foo {
      padding: double(env(--branding-padding));
    }
  `),
  visitor: composeVisitors([environmentVisitor, doubleFunctionVisitor])
});

assert.equal(res.code.toString(), '.foo{padding:40px}');

Each visitor object has the opportunity to visit every value once. If a visitor returns a new value, that value is visited by the other visitor objects but not again by the original visitor that created it. If other visitors subsequently modify the value, the previous visitors will not revisit the value. This is to avoid infinite loops.

Unknown at-rules

By default, unknown at-rules are stored in the AST as raw tokens. This allows you to interpret them however you like by writing a custom visitor. The following example allows declaring static variables using named at-rules, and inlines them when an at-keyword token is seen:

let declared = new Map();
let res = transform({
  filename: 'test.css',
  minify: true,
  code: Buffer.from(`
    @blue #056ef0;

    .menu_link {
      background: @blue;
    }
  `),
  visitor: {
    Rule: {
      unknown(rule) {
        declared.set(rule.name, rule.prelude);
        return [];
      }
    },
    Token: {
      'at-keyword'(token) {
        return declared.get(token.value);
      }
    }
  }
});

assert.equal(res.code.toString(), '.menu_link{background:#056ef0}');

Custom at-rules

Raw tokens as stored in unknown at-rules are fine for simple cases, but in more complex cases, you may wish to interpret a custom at-rule body as a standard CSS declaration list or rule list. However, by default, Lightning CSS does not know how unknown rules should be parsed. You can define their syntax using the customAtRules option.

The syntax of the at-rule prelude can be defined with a CSS syntax string, which Lightning CSS will interpret and use to validate the input CSS. This uses the same syntax as the @property rule. The body syntax is defined using one of the following options:

This example defines two custom at-rules. @mixin defines a reusable style block, supporting both directly nested declarations and nested rules. A visitor function registers the mixin in a map and removes the custom rule. @apply looks up the requested mixin in the map and returns the nested rules, which are inlined into the parent.

let mixins = new Map();
let res = transform({
  filename: 'test.css',
  minify: true,
  targets: { chrome: 100 << 16 },
  code: Buffer.from(`
    @mixin color {
      color: red;

      &.bar {
        color: yellow;
      }
    }

    .foo {
      @apply color;
    }
  `),
  customAtRules: {
    mixin: {
      prelude: '<custom-ident>',
      body: 'style-block'
    },
    apply: {
      prelude: '<custom-ident>'
    }
  },
  visitor: {
    Rule: {
      custom: {
        mixin(rule) {
          mixins.set(rule.prelude.value, rule.body.value);
          return [];
        },
        apply(rule) {
          return mixins.get(rule.prelude.value);
        }
      }
    }
  }
});

assert.equal(res.code.toString(), '.foo{color:red}.foo.bar{color:#ff0}');

Examples

For examples of visitors that perform a variety of real world tasks, see the Lightning CSS visitor tests.

Publishing a plugin

Visitor plugins can be published to npm in order to share them with others. Plugin packages simply consist of an exported visitor object, which users can compose with other plugins via the composeVisitors function as described above.

// lightningcss-plugin-double-function
export default {
  FunctionExit: {
    double(f) {
      // ...
    }
  }
};

Plugins can also export a function in order to accept options.

// lightningcss-plugin-env
export default (values) => ({
  EnvironmentVariable(env) {
    return values[env.name];
  }
});

Plugin package names should start with lightningcss-plugin- and be descriptive about what they do, e.g. lightningcss-plugin-double-function. In addition, they should include the lightningcss-plugin keyword in their package.json so people can find them on npm.

{
  "name": "lightningcss-plugin-double-function",
  "keywords": ["lightningcss-plugin"],
  "main": "plugin.mjs"
}

Using plugins

To use a published visitor plugin, install the package from npm, import it, and use the composeVisitors function as described above.

import { transform, composeVisitors } from 'lightningcss';
import environmentVisitor from 'lightningcss-plugin-environment';
import doubleFunctionVisitor from 'lightningcss-plugin-double-function';

let res = transform({
  filename: 'test.css',
  minify: true,
  code: Buffer.from(`
    .foo {
      padding: double(env(--branding-padding));
    }
  `),
  visitor: composeVisitors([
    environmentVisitor({
      '--branding-padding': {
        type: 'length',
        value: {
          unit: 'px',
          value: 20
        }
      }
    }),
    doubleFunctionVisitor
  ])
});

assert.equal(res.code.toString(), '.foo{padding:40px}');