saficss

Tiny runtime CSS-in-JS for plain HTML. Write a style object, get a class name. No build step.


Keywords
css-in-js, css, runtime, no-build, cdn, styling
License
MIT
Install
npm install saficss@0.1.0

Documentation

SafiCSS

Tiny runtime CSS-in-JS for plain HTML. You write your styles as a JavaScript object, call one function, and it styles the element. No build step, no framework. Drop it in any HTML file over a CDN, or install it from npm. It reaches all of CSS: hover, media queries, and keyframes, not only inline styles.

css(object) turns the object into real CSS, generates a class name, injects the rules into one <style> tag, and hands back the class. The same object always gets the same class, so nothing is duplicated. Because it injects a class instead of an inline style string, pseudo-classes, media queries, and animations all work.

  • Zero runtime dependencies
  • Ships TypeScript types, so your editor autocompletes property names and flags bad values
  • About 1.3 KB gzipped
  • Works from npm and from a CDN, as an ES module or a plain <script> global

Install

npm i saficss
import { css } from "saficss";

document.getElementById("box").className = css({
  background: "#444",
  color: "#fff",
  padding: 16,
});

Use from a CDN

ES module

<div id="box"></div>
<script type="module">
  import { css } from "https://esm.sh/saficss";

  const accent = "#22d3ee"; // a normal JS variable

  document.getElementById("box").className = css({
    background: "#444",
    color: accent,
    padding: 16,
    borderRadius: 8,
    "&:hover": { background: "#666" },
    "@media (max-width: 600px)": { padding: 8 },
  });
</script>

Plain script tag (global)

Load the IIFE build and use window.SafiCSS:

<div id="box"></div>
<script src="https://cdn.jsdelivr.net/npm/saficss/dist/saficss.global.js"></script>
<script>
  document.getElementById("box").className = SafiCSS.css({
    background: "#444",
    color: "#fff",
    padding: 16,
  });
</script>

Serve it straight from GitHub

jsDelivr serves any file in the repo without an npm publish. Point at a tag or commit for a stable URL:

<!-- ES module -->
<script type="module">
  import { css } from "https://cdn.jsdelivr.net/gh/Abdulkader-Safi/SafiCSS@v0.1.0/dist/saficss.mjs";
</script>

<!-- Global script -->
<script src="https://cdn.jsdelivr.net/gh/Abdulkader-Safi/SafiCSS@v0.1.0/dist/saficss.global.js"></script>

Change @v0.1.0 to whatever tag you want. The built files live in dist/ and are committed to the repo, so these URLs work as soon as the tag is pushed. Forking this? Swap Abdulkader-Safi/SafiCSS for your own owner/repo.

For production, pin a version (never @latest) and add Subresource Integrity to the <script> tag: integrity="sha384-..." crossorigin="anonymous". jsDelivr shows the hash for any file on its page.

API

css(styleObject) => string

Returns a class name. Injects the rules once, keyed by a hash of the object.

const cls = css({
  color: "white",
  padding: 16,
  "&:hover": { color: "cyan" },
  span: { fontWeight: 700 },
  "@media (max-width: 600px)": { padding: 8 },
});
el.className = cls;

Key rules:

  • Keys are CSS properties in camelCase (backgroundColor) or kebab strings ("background-color"). Both work.
  • String and number values become declarations. A number gets px, except for the unitless properties React treats specially (opacity, zIndex, fontWeight, lineHeight, flex, order, gridColumn, and the rest). Custom properties (--foo) never get a unit.
  • An object value is a nested block. A key with & is replaced by the generated class ("&:hover", "& > a"). A key starting with @ is an at-rule (@media, @supports). Any other key is nested under the class ("span" becomes .saficss-xxx span).
  • An array value sets fallbacks for one property (display: ["grid", "flex"]).

keyframes(framesObject) => string

Injects an @keyframes block and returns the animation name. Use it in an animation value.

const spin = keyframes({
  from: { transform: "rotate(0deg)" },
  to: { transform: "rotate(360deg)" },
});

el.className = css({ animation: `${spin} 1s linear infinite` });

injectGlobal(styleObject) => void

Writes unscoped global rules: resets, :root custom properties, body styles. Top-level keys are selectors.

injectGlobal({
  ":root": { "--accent": "#22d3ee" },
  body: { margin: 0, fontFamily: "system-ui" },
  "*": { boxSizing: "border-box" },
});

setVars(vars, target?) => void

Sets CSS custom properties at runtime. Defaults to :root; pass an element to scope them. Define var(--accent) in your styles, then call setVars to change it live. Everything using the variable updates with no re-injection.

setVars({ "--accent": "#f00" }); // on :root
setVars({ accent: "blue" }, myEl); // scoped, "--" added for you

Default export

The default export bundles all four functions, which is what the global build exposes as window.SafiCSS.

import SafiCSS from "saficss";
SafiCSS.css({ color: "red" });

TypeScript

The style object is fully typed on top of csstype, so every standard CSS property autocompletes and a bad value is a type error:

css({ borderColor: 42 }); // Error: number is not assignable to a color
css({ colr: "red" }); // Error: unknown property (typo caught)

That is the linting for anyone using SafiCSS. No stylelint needed for the object API. TypeScript is the linter here.

Development

npm run typecheck   # tsc --noEmit
npm run lint        # eslint
npm run test        # vitest
npm run build       # tsup, writes dist/
npm run format      # prettier --write

License

MIT, Abdulkader Safi.