Color & gradient
The color engine is the deepest part of the kit: a wide-gamut OKLCH picker with CSS Color 4 gamut mapping, shared by the color control and the gradient editor. All four controls on this page are lazy: on the code-split build their modules load the first time a schema asks for them.
Color
Any hex or CSS color-function string is recognized as a shorthand. Hex in, but the picker
works in OKLCH and can emit hex, rgb(), hsl(), oklch()
and the other wide-gamut notations (switch the format inside the picker). P3-only colors
survive instead of clipping. The param is always a CSS-ready string.
Everything in the picker takes the keyboard: Tab to the plane and arrow it (↑/↓ lightness, ←/→ chroma, ⇧ for ×10, Page Up / Page Down for ten lightness steps), the hue and alpha strips are sliders (the arrow keys, Home/End), and the channel fields type or arrow-step.
const panel = tweaks("Color", {
tint: "#7C5CFF", // or { type: "color", value: "oklch(0.65 0.24 295)" }
});
mount.append(panel.el);
const stage = mount.parentElement; // the box behind the panel
const apply = (p) => { stage.style.background = p.tint; };
panel.on(apply);
panel.ready.then(() => apply(panel.params));mount is the panel's slot inside the stage. In your own page you'd just document.body.append(panel.el).
Gradient
A stop editor: drag stops along the bar (or Tab to one and nudge
it with the arrow keys), double-click the bar (or the + button) to add, select a stop
to recolor it with the full picker, Delete (or Backspace) to remove it, down to two. The value is
{ stops: [{ color, pos }], interpolation, easing }; hand it to
gradientCss(value, angle?) for the CSS. Stops can be authored in
oklch() for wide-gamut ramps.
The ramp blends in whichever color space you pick in the stop editor: switch the mode
to RGB and the blend goes through sRGB (muddier; that's what rgb() blends
look like); OKLCH stays perceptually even. That chosen space rides along as
interpolation, so dropping it into linear-gradient(in …) makes
your CSS match the preview exactly.
Beside the mode, easing. A gradient blends in a straight line between
stops, and the eye reads where a straight blend starts and stops as an edge, most
visibly in a fade to transparent. Pick ease-in-out (or any CSS easing) and
the ramp eases between each pair of stops instead; it rides along as easing.
No browser eases a gradient natively, so gradientCss() samples the curve
into 16 stops per segment, each a color-mix() in the ramp's own blend
space. gradientStops(value) is the stop list alone, for a conic or radial
gradient.
const panel = tweaks("Gradient", {
ramp: { type: "gradient", value: { stops: [
{ color: "oklch(0.72 0.19 25)", pos: 0 },
{ color: "oklch(0.86 0.17 95)", pos: 0.5 },
{ color: "oklch(0.72 0.16 280)", pos: 1 },
] } },
angle: [90, 0, 360, 1],
});
mount.append(panel.el);
// gradientCss honors the editor's blend space and expands its easing, so the box
// behind the panel is the picker's preview at the chosen angle.
const stage = mount.parentElement;
const apply = (p) => { stage.style.background = gradientCss(p.ramp, p.angle); };
panel.on(apply);
panel.ready.then(() => apply(panel.params));Shadow
{ type: "shadow" } edits a box-shadow: offset, blur,
spread, inside or outside, and the color from the same picker, in one popover. The
param is the fields plus css, the shadow as CSS, so
el.style.boxShadow = p.lift.css is the whole wiring. Author it as fields
or as a box-shadow string.
The pane's preview draws the shadow on the element it's for, scaled down to fit, so it
reads there as it will on the page. Say how big that element is with size, its width
in px or [width, height] (in markup, data-size="440,440"): a shadow made
for a 440px plate then shows as it falls on the plate, where the preview's own 64px card would
lose a large negative spread altogether. Without size, the card grows from 64px only
as far as the shadow needs to stay in view, sized as the pane opens and held while you edit.
const card = target.querySelector(".sh-card");
const panel = tweaks("Shadow", {
lift: {
type: "shadow", y: 12, blur: 32, spread: -4, color: "rgb(124 92 255 / 0.35)",
// or the line above as value: "0 12px 32px -4px #7c5cff59"
size: [150, 100], // the card's, so the pane previews the shadow on it
},
});
mount.append(panel.el);
const apply = (p) => { card.style.boxShadow = p.lift.css; };
panel.on(apply);
panel.ready.then(() => apply(panel.params));Image
{ type: "image" } is a drop zone and file picker in one row; the
param is a data URL. Give it options (URLs, or { value, label })
and it becomes a gallery: the row names the chosen image and opens a pane of tiles to pick
from, the upload below them, and Clear, which clears the param to "". An upload stays in the browser and is held to 10 MB
(maxSize, in bytes). A picture longer than 2560px on its long side is drawn
down to it first (fit, in px, or false to keep uploads as they are),
and one over 1.5 MB is redrawn at its size, so a phone photo can't fill storage. A JPEG stays a JPEG at quality 0.85;
anything else stays a PNG when that comes to 1.5 MB or less, as a screenshot does, so its
text isn't smudged, and past that is a JPEG, or a WebP when it has transparency. The redraw keeps
Display P3 where the browser can. A GIF, an SVG, or an animated PNG or WebP is kept as it is, and a JPEG or PNG kept as it
is loses its Exif and XMP, GPS included, so a photo's location doesn't ride into a save or a paste. An Ultra HDR photo
keeps the XMP that holds only its gain map, so it stays HDR. upload: false leaves only the tiles,
clear: false leaves out Clear for a choice that must keep a value, and
cols sets the tiles per row, up to 6.
const tile = target.querySelector(".img-tile");
const panel = tweaks("Image", {
texture: {
type: "image",
value: "./textures/dusk.svg",
options: ["./textures/dusk.svg", "./textures/waves.svg", "./textures/grid.svg", "./textures/dots.svg"],
},
});
mount.append(panel.el);
const apply = (p) => {
tile.style.backgroundImage = p.texture ? `url(${p.texture})` : "none";
tile.querySelector("span").style.opacity = p.texture ? 0 : 1;
};
panel.on(apply);
panel.ready.then(() => apply(panel.params));