Pop PHP
The Toolkit

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.

BASH
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:

PHP
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;
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:

PHP
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:

PHP
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:

PHP
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);
CSS
#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:

PHP
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:

PHP
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);
CSS
/* 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:

PHP
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:

PHP
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:

PHP
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

CSS
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:

PHP
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);
CSS
.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:

PHP
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:

PHP
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:

PHP
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:

PHP
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:

PHP
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#

  • Imagespop-color values 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