Implementing CSS color functions
It is unfortunate that CSS evolves a lot slower than JavaScript. One of the reasons is that JavaScript is standardised by Ecma while CSS is being worked on by the W3C CSS Working Group.
Lately color functions have been postponed: removed from Color Module lvl 4 and included in Color Module lvl 5. For those who don't know: while JavaScript is based on the premise of stages where higher is better, CSS is based on snapshots and modules where higher does not mean better.
So even though logic in CSS is becoming more sophisticated (calc, max etc...) we still have to rely on preprocessors to deal with color calculations.
Unless we write it ourselves
We could just loop the stylesheets with JavaScript in search for unresolved calculations and apply them.
If this were to be proper open-source it would have to work for a lot of cases. But for now I'm going to set some clear boundaries. The functions are only applied to variables (or custom properties) in the CSS :root
node. The calculations will be triggered manually: we're not going to use watchers or change listeners.
We also need to decide on an API. Not to reïnvent the wheel I'll think I'll stick with the Sass implementation for now.
So let's get to it
The idea is:
- loop the stylesheets and index variables that use color function
- create a new rule and overwrite the variables with the applied color functions
Looping the stylesheets is easy enough (we'll have to use try..catch in case we access external stylesheets).
To weed out any valid functions we can use getComputedStyle
. So for each definition we end up with an initial value and a computed value. Variable definitions are stored in an object because we might need their values if they are used in a function.
Color functions are stored in a separate object because we'll need to loop them separately.
interface ICSSVar {
name: string
, value: string
, computed: string
}
const cssVars:{[key:string]:ICSSVar} = {}
const cssVarFunctions:{[key:string]:ICSSVar} = {}
const colorFunctionsRegex = /^\s*(lighten|darken|desaturate|adjust|alpha)\(/
const computedStyle = getComputedStyle(document.body)
Array.from(document.styleSheets).forEach(sheet=>{
const rules = []
try { rules.push(...Array.from(sheet.rules)) } catch (err) {}
rules
.filter((rule:CSSStyleRule)=>rule.selectorText===':root')
.forEach((rule:CSSStyleRule)=>{
const {style} = rule
for (let i = 0, l = style.length; i < l; i++) {
const name = style[i]
const value = style.getPropertyValue(name).trim()
const computed = computedStyle.getPropertyValue(name).trim()
const cssVar:ICSSVar = {name, value, computed}
const isFnc = colorFunctionsRegex.test(computed)
cssVars[name] = cssVar
isFnc&&(cssVarFunctions[name] = cssVar)
}
})
})
Now what?
We have the function names and parameters, how do we run them?
We could use regex to dismantle them but that would get messy pretty soon because functions can be nested. Even CSS variables use a lookup function: var(--my-value)
.
I'm afraid we're going to have to write... (imagine spooky sound effects here)... a parser.
I'm a very visual programmer and I imagine a lot of front-end developers are. I've always steered clear of lexers, parsers and AST thinking it was for smart people, with thick glasses.
But it's really not all that hard once you get into it: just really verbose and meticulous.
Besides, we'll only be parsing some functions and we can cut a few corners implementing it.
For our corner cutting: we'll just lexe our function string straight into an AST (of sorts).
The following is crude and simple but produces the tree we want.
const type = {
FUNCTION: 'FUNCTION'
, STRING: 'STRING'
, NUMBER: 'NUMBER'
}
const paren1 = '('
const paren0 = ')'
const comma = ','
const space = ' '
const regexNumeric = /^\d+(\.\d+)?$/
export interface IParam {
type: string
, name?: string
, params?: IParam[]
, value?: string|number
}
export function parse(string:string, result:IParam[] = []) {
let indent = 0
let start = 0
let name = ''
for (let index = 0, length = string.length; index < length; index++) {
const character = string[index]
const isParen1 = character === paren1
const isParen0 = character === paren0
const isComma = character === comma
const isSpace = character === space
const isDelimiter = isParen0 || isComma || isSpace
const isLastIndex = index === length - 1
const wasIndentZero = indent === 0
if (isParen1 && wasIndentZero) {
name = string.substring(start, index).trim()
start = index + 1
}
isParen1 && indent++
isParen0 && indent--
const isIndentZero = indent === 0
if (isIndentZero) {
if (name && isParen0) {
const value = string.substring(start, index).trim()
const params:IParam[] = parse(value.substr(0, value.length).trim(), [])
result.push({
type: type.FUNCTION
, name
, params
})
name = ''
start = index
} else if (!name && (isDelimiter || isLastIndex) && start !== index) {
const substring = string.substring(start, index + (isLastIndex ? 1 : 0)).trim()
const isValid = substring !== comma && substring !== paren0
const isNumeric = regexNumeric.test(substring)
substring && isValid && result.push({
type: isNumeric ? type.NUMBER : type.STRING
, value: isNumeric ? parseFloat(substring) : substring
})
start = index + 1
}
}
}
return result
}
You may notice that this is recursive. When a left- or right-paren is encountered indent
will change. When the indent
is back to zero we should have a result.
Then the parameters are fed into the same parse function. The parser then may encounter another function; which turns our resulting object into a happy little tree.
Once we have that AST-like tree we have something we can work with. All that is left now is to traverse the tree and apply any of the functions we encounter.
function getFunction(name:string):Function{
return {
var: (name:string) => cssVars[name].computed
, lighten: (clr:string, value:number) => tinycolor(clr).lighten(value)
, darken: (clr:string, value:number) => tinycolor(clr).darken(value)
, desaturate: (clr:string, value:number) => tinycolor(clr).desaturate(value)
, adjust: (clr:string, value:number) => tinycolor(clr).spin(value)
, alpha: (clr:string, value:number) => tinycolor(clr).setAlpha(value)
}[name] || ((...arg:any)=>arg.join('-'))
}
function parseTree(tree:IParam):string{
const {name, params, value} = tree
return value!==undefined
&&value
||getFunction(name)(...params.map(o=>o.value||parseTree(o)))
}
function resetVars(){
indexVars()
Object.values(cssVarFunctions).forEach((cssVar:ICSSVar)=>{
const {name, value} = cssVar
const tree = parse(value)
const newValue = parseTree(tree[0])
setVar(name, newValue)
cssVars[name].computed = newValue
})
}
A working example
The original project I used this for is in TypeScript. For the demo below I did a quick port to JavaScript (turned interfaces into JSDoc type definitions).
The input[type=color]
below only sets --color-main
onto the documentElement
style. The derived colors are computed through the above resetVars
method.
For the actual color conversion a third party package is used. The only reason I choose this one is that it is used by this online Sass color calculator, which I used to get the derived color values.
NPM?
I might one day put this on NPM. But having a working example is one thing, open-sourcing it is an entirely different thing. So for now just checkout the code on JSFiddle and do whatever you like.