CSS & Color
Two components sit behind this page. pop-css builds a stylesheet as objects — selectors, media
queries, comments — renders it, parses one back, and minifies it. pop-color is the value type those
properties hold: one class per color space, converting between twelve of them, and used by pop-image
and pop-pdf as well.
composer require popphp/pop-css popphp/pop-color
Generating CSS#
A stylesheet is a Pop\Css\Css object holding Pop\Css\Selector objects. A selector takes any valid
CSS selector string and a set of properties:
use Pop\Css\Css;
use Pop\Css\Selector;
$css = new Css();
$html = new Selector('html');
$html->setProperties([
'margin' => 0,
'padding' => 0,
'background-color' => '#fff',
]);
$login = new Selector('#login');
$login->setProperty('margin', 0)
->setProperty('padding', 0);
$css->addSelectors([$html, $login]);
echo $css;
html {
margin: 0;
padding: 0;
background-color: #fff;
}
#login {
margin: 0;
padding: 0;
}
Selectors render in the order you add them, whatever their kind — nothing is grouped or reordered.
render() returns the same string echo prints, and writeToFile() writes it to disk.
Properties are reachable three ways, all writing to the same store: setProperty(), a magic property,
and array access. hasProperty(), getProperties() and removeProperty() read and clear them:
use Pop\Css\Selector;
$box = new Selector('.box');
$box->setProperty('color', '#333');
$box->padding = '10px';
$box['margin'] = 0;
$box->hasProperty('color'); // true
$box->removeProperty('color');
A margin or padding shorthand can be read back as longhand without ever having been set that way —
the value is split by CSS's own one-to-four-value rules, and an explicit longhand overrides the
synthesized one:
use Pop\Css\Selector;
$box = new Selector('.box');
$box->setProperty('margin', '10px 20px 30px 40px');
$box['margin-top']; // '10px'
$box['margin-right']; // '20px'
$box['margin-bottom']; // '30px'
$box['margin-left']; // '40px'
Media queries are their own object, holding their own selectors, added to the stylesheet alongside the top-level ones:
use Pop\Css\Css;
use Pop\Css\Media;
use Pop\Css\Selector;
$login = new Selector('#login');
$login->setProperty('width', '50%');
$media = new Media('screen');
$media->setFeature('max-width', '480px');
$media['#login'] = new Selector();
$media['#login']->setProperty('width', '75%');
echo (new Css())->addSelector($login)->addMedia($media);
#login {
width: 50%;
}
@media screen and (max-width: 480px) {
#login {
width: 75%;
}
}
Assigning into a Media or a Css by array key names the selector for you, which is why new Selector()
above needs no argument. Media's constructor takes the type, features, a not/only condition and a tab
size in one call, and both Selector and Media take that tab size last to control indentation.
Once a selector is on the stylesheet it is addressable by name, which is what makes a generated
stylesheet editable rather than write-once. The Css object is countable, iterable and supports array
access, and assigning into a key adds the selector under that name:
use Pop\Css\Css;
use Pop\Css\Selector;
$css = new Css();
$css->addSelector(new Selector('html'));
$css['#login'] = new Selector();
$css['#login']->setProperty('width', '50%');
$css->hasSelector('html'); // true
$css->getSelector('html')->setProperty('margin', 0);
$css->removeSelector('html');
count($css); // 1
Media queries are held in their own indexed list rather than by name: getAllMedia() returns them all,
getMedia(0) one by position, and removeMedia(0) and removeAllMedia() clear them. A query's
not/only condition is setCondition(), and its features read back through getFeatures(),
hasFeature() and getFeature().
addComment() works on a Css, a Media or a Selector, and puts the comment above whatever it was
added to. Its second and third arguments are a wrap width and a trailing-newline flag; a wrap of 0
forces a single-line comment:
use Pop\Css\Css;
use Pop\Css\Selector;
$p = new Selector('p');
$p->setProperty('width', '50%');
$p->addComment('Body copy', 0, false);
echo new Css($p);
/* Body copy */
p {
width: 50%;
}
Parsing CSS#
Parsing gives you back the same objects you would have built, so an existing stylesheet can be read, edited and written out again:
use Pop\Css\Css;
$css = Css::parseFile(__DIR__ . '/../public/css/styles.css');
$css->getSelector('html')->setProperty('background-color', '#111');
$css->writeToFile(__DIR__ . '/../public/css/styles.dark.css');
Css::parseString() takes CSS as a string and Css::parseUri() fetches it over HTTP. Each has an
instance form — parseCssFile(), parseCss() and parseCssUri() — that merges into a Css object
you already hold rather than returning a new one. parseFile() on a path that is not there throws
Pop\Css\Exception ("Error: That file '...' does not exist.").
A parsed stylesheet is countable and iterable, and selectors are addressed by name:
use Pop\Css\Css;
$css = Css::parseString('html { margin: 0; } #login, #signup { width: 50%; }');
count($css); // 2
$css->hasSelector('html'); // true
foreach ($css as $name => $selector) {
$selector->getProperties();
}
A grouped selector stays grouped: #login, #signup comes back as one Selector whose name is the
whole string, not two. isMultipleSelector() and hasDescendant() tell you which shape you are
holding. Media queries live in a separate list, reached with getAllMedia() and getMedia(0) by
index, and removed with removeMedia(0) or removeAllMedia().
Comments survive a round trip, and are readable with getComments() and hasComments() on a Css,
Media or Selector alike.
Minifying CSS#
minify(true) is a flag on the Css object, not a separate call — set it, then render:
use Pop\Css\Css;
$css = Css::parseFile(__DIR__ . '/../public/css/styles.css');
$css->minify(true);
$css->writeToFile(__DIR__ . '/../public/css/styles.min.css');
It strips comments, newlines and the space around : and ;. What it does not do is rewrite values,
collapse shorthands, or remove every last space: the space inside #login, #signup stays, and so does
the one before @media. Two selectors and a media query minify to
html{margin:0;padding:0;}#login, #signup{width:50%;color:#333;} @media screen and (max-width: 480px) {#login{width:75%;}}
That's enough for a build step that wants the bytes down without another toolchain. Reach for a dedicated minifier when you need the last few percent.
Color Values#
Pop\Color\Color is a factory, one static method per color space, each returning that space's own
class. A selector property takes a color object anywhere it takes a string:
use Pop\Color\Color;
use Pop\Css\Css;
use Pop\Css\Selector;
$box = new Selector('.box');
$box->setProperty('color', Color::rgb(255, 0, 0));
$box->setProperty('background-color', Color::cmyk(30, 20, 10, 5));
$box->setProperty('border-color', Color::oklch(0.7, 0.15, 30));
echo new Css($box);
.box {
color: rgb(255, 0, 0);
background-color: rgb(170, 194, 218);
border-color: oklch(0.7 0.15 30);
}
The value is normalized to valid CSS when it is set. CMYK is not CSS syntax, so it converts through RGB on the way in; OKLCH is CSS Color 4 syntax and passes through as written.
Channels read back through getters, array access or magic properties, all equivalent, and toArray()
returns every channel including alpha:
use Pop\Color\Color;
$rgb = Color::rgb(120, 60, 30, 0.5);
$rgb->getR(); // 120
$rgb['g']; // 60
$rgb->b; // 30
$rgb->getA(); // 0.5
$rgb->toArray(); // ['r' => 120, 'g' => 60, 'b' => 30, 'a' => 0.5]
Hex handles alpha in both CSS forms, four-digit #RGBA and eight-digit #RRGGBBAA, alongside the
three- and six-digit ones, and exposes it as the same 0-to-1 float the other classes use:
use Pop\Color\Color;
$hex = Color::hex('#ff880080');
echo $hex; // #ff880080
echo $hex->getA(); // 0.502
Color::parse() builds a color from a string, dispatching on the string's shape rather than needing a
format hint. It recognizes rgb()/rgba(), hsl(), hsv(), hsb(), hwb(), lab(), lch(),
oklab(), oklch(), #hex in three, four, six and eight digits, space-separated CMYK, and a bare
number as grayscale:
use Pop\Color\Color;
Color::parse('rgba(120, 60, 30, 0.5)'); // Pop\Color\Color\Rgb
Color::parse('#783c1e'); // Pop\Color\Color\Hex
Color::parse('hwb(210 20% 10% / 0.5)'); // Pop\Color\Color\Hwb
Color::parse('oklch(0.628 0.2577 29)'); // Pop\Color\Color\Oklch
A string it cannot place throws Pop\Color\Color\Exception ("Error: The string was not in the
correct color format."), and any channel outside its range throws \OutOfRangeException from the
setter — Color::rgb(300, 0, 0) gives "Error: The value of $r must be between 0 and 255."
getColorProperty() reads a property back off a selector as a color object, which is what you want
after parsing someone else's stylesheet:
use Pop\Color\Color;
use Pop\Css\Selector;
$box = new Selector('.box');
$box->setProperty('color', Color::rgb(255, 0, 0));
echo $box->getColorProperty('color')->toHex(); // #ff0000
What comes back is whichever class matches the stored string, so the methods available depend on the value.
The property above holds rgb(255, 0, 0), so the result is an Rgb. Had it been '#ff0000', you'd get a
Hex — see the next section for which conversions each class carries.
Check getColorProperty()'s return type before using it. Color::parse()'s shape heuristics are
permissive, so ordinary values can come back as color objects.
Converting Between Color Spaces#
Twelve spaces are supported, one class each. Pop\Color\Color\Rgb is the hub: it's the only class
carrying a conversion to every other one, and every other class carries a toRgb(). Convert between
two non-RGB spaces by going through it — Color::hex('#783c1e')->toRgb()->toOklch().
| Space | Factory | Default string | Direct conversions besides toRgb() |
|---|---|---|---|
| RGB | Color::rgb(120, 60, 30) |
rgb(120, 60, 30) |
every other space |
| Hex | Color::hex('#783c1e') |
#783c1e |
toHsl() |
| HSL | Color::hsl(20, 75, 47) |
hsl(20, 75%, 47%) |
toHex() |
| HSV | Color::hsv(210, 50, 75) |
rgb(96, 143, 191) |
none |
| HSB | Color::hsb(210, 50, 75) |
rgb(96, 143, 191) |
none |
| HWB | Color::hwb(210, 20, 10) |
hwb(210 20% 10%) |
none |
| CMYK | Color::cmyk(30, 20, 10, 5) |
0.3 0.2 0.1 0.05 |
toGray() |
| Grayscale | Color::grayscale(50) |
0.5 |
toCmyk() |
| Lab | Color::lab(53.24, 80.09, 67.2) |
lab(53.24% 80.09 67.2) |
toLch() |
| Lch | Color::lch(53.24, 104.55, 40) |
lch(53.24% 104.55 40) |
toLab() |
| Oklab | Color::oklab(0.628, 0.2249, 0.1258) |
oklab(0.628 0.2249 0.1258) |
toOklch() |
| Oklch | Color::oklch(0.628, 0.2577, 29) |
oklch(0.628 0.2577 29) |
toOklab() |
Three of those default strings are not CSS. CMYK and Grayscale render as space-separated numbers, the
format pop-pdf wants, and HSV and HSB have no CSS syntax of their own so they delegate to rgb(...).
toCss() is on every class and always returns something a browser accepts:
use Pop\Color\Color;
$cmyk = Color::cmyk(30, 20, 10, 5);
echo $cmyk; // 0.3 0.2 0.1 0.05
echo $cmyk->toCss(); // rgb(170, 194, 218)
Alpha is a float from 0 to 1 and rides along through conversions, but it's not named the same
everywhere. Most classes use getA()/setA()/hasA(). The four Lab-family classes use
getAlpha()/setAlpha()/hasAlpha(), because Lab and Oklab already spend a on their own axis
channel — so Color::lab(53.24, 80.09, 67.2, 0.5)->getA() returns 80.09, the axis, and getAlpha()
returns 0.5.
RGB channels are integers, so a round trip through RGB loses a little precision from spaces with finer resolution.
Rendering options, the comment API and the full set of color setters go past what this page covers — see the pop-css and pop-color READMEs.
See Also#
- Images —
pop-colorvalues passed to the drawing and fill APIs - PDFs — where the space-separated CMYK and grayscale strings come from
- DOM — the markup these stylesheets are written against
- pop-css README — every selector, media and comment method
- pop-color README — every color class and conversion