Implement jQuery css()

Hard45 minLinkedIn

Prompt

Build a small version of jQuery's css() method. You expose a function jq(selector) that returns an object with a css method. The method is overloaded: the same name reads styles in some calls and writes styles in others. The shape of the arguments tells you which.

<div>
<p id="test" style="color: red; opacity: 1;">
Hello World
</p>
</div>

Read one property (from the first matched element):

jq('#test').css('color');
// "rgb(255, 0, 0)"

Read several properties at once:

jq('#test').css(['color', 'opacity']);
// { color: "rgb(255, 0, 0)", opacity: "1" }

Set one property on every matched element:

jq('#test').css('color', 'green');

Set per-element via a function:

function setColor(index, currentValue) {
return index % 2 === 0 ? 'orange' : 'green';
}
jq('p').css('color', setColor);

If the function returns undefined for an element, that element is left alone.

Set several properties via an object:

jq('p').css({ color: 'red', opacity: 1 });

The set forms return the chain object so calls can be chained: jq(el).css('color', 'red').css('opacity', 0.5).

Requirements

  • css(propertyName) returns the computed value of the property for the first matched element
  • css([propertyName, ...]) returns an object with each requested property as a key and its computed value
  • css(propertyName, value) writes the value to every matched element's inline style
  • css(propertyName, fn) calls fn(index, currentValue) for each matched element with the element bound as this; undefined from the function leaves that element unchanged
  • css(properties) writes every key of the object to every matched element's inline style
  • Set calls return the chain so jq(el).css(a).css(b) works
  • If the selector matches no elements, the get forms return undefined or an empty object, and the set forms are a no-op that still returns the chain

Playground

Hint 1

Start by collecting the matched elements. If the argument is a string, use document.querySelectorAll. Wrap the result in an array so you can map and iterate freely.

Hint 2

Dispatch on the shape of args inside css. One string: get single. One array: get many. One object: set many. Two arguments: set single (value or function). This is the main structure of the whole solution.

Hint 3

For get, read with getComputedStyle(el).getPropertyValue(prop). For set, write with el.style.setProperty(prop, String(value)). Always return the chain object from a set so callers can chain.

Solution

Explanation

The whole problem is pattern matching on the arguments. Once you see that, each branch is short.

Collect the elements first

jq takes a selector and produces an object that wraps a list of elements. querySelectorAll returns a NodeList, so convert it to an array. That way we can use forEach, for...of, and map naturally.

const elements =
typeof selector === 'string'
? Array.from(document.querySelectorAll(selector))
: [selector];

Accepting a raw element in addition to a string is a small convenience that mirrors real jQuery. jq(elementRef).css(...) works without writing a unique selector first.

One method, five call shapes

Inside css, the argument shape determines what to do. Handle the cases in a fixed order:

  1. One argument, string → get a single value.
  2. One argument, array → get multiple values and return an object.
  3. One argument, plain object → set every property on every element.
  4. Two arguments, second is a function → set per element using the function's return value.
  5. Two arguments, second is anything else → set the value on every element.

The order matters only between (1) and (2), since typeof 'x' === 'string' and Array.isArray(['x']) do not overlap. It does matter to check (3) after (2), because arrays are also typeof 'object'.

Get uses computed styles

getComputedStyle(el) returns the browser's final, cascaded style for the element. It reads both inline styles and stylesheet rules. getPropertyValue('color') gets the value of a single property and works with kebab-case names.

if (args.length === 1 && typeof args[0] === 'string') {
if (elements.length === 0) return undefined;
return getComputedStyle(elements[0]).getPropertyValue(
args[0]
);
}

The array form iterates the list of names and builds a plain object:

if (args.length === 1 && Array.isArray(args[0])) {
const result = {};
if (elements.length === 0) return result;
const styles = getComputedStyle(elements[0]);
for (const prop of args[0]) {
result[prop] = styles.getPropertyValue(prop);
}
return result;
}

Only the first element is read. That matches jQuery: there is no sensible way to collapse three different colors into one return value, so the first element wins.

Set writes to inline styles

Get reads the cascade, but set writes only to the element's inline style. el.style.setProperty takes kebab-case names and accepts any string value.

if (
args.length === 1 &&
args[0] !== null &&
typeof args[0] === 'object'
) {
const properties = args[0];
for (const el of elements) {
for (const prop in properties) {
el.style.setProperty(prop, String(properties[prop]));
}
}
return chain;
}

Wrapping the value in String(...) handles numeric inputs like opacity: 1 or opacity: 0.5 without a special case.

The function form

The function gets the index of the element and the current computed value of the property, with this bound to the element itself. Whatever it returns becomes the new inline value. If it returns undefined, do nothing for that element. Using value.call(el, index, currentValue) is what makes the this binding work; plain value(index, currentValue) would leave this unset in strict mode.

if (args.length === 2) {
const [prop, value] = args;
elements.forEach((el, index) => {
if (typeof value === 'function') {
const currentValue =
getComputedStyle(el).getPropertyValue(prop);
const newValue = value.call(el, index, currentValue);
if (newValue !== undefined) {
el.style.setProperty(prop, String(newValue));
}
} else {
el.style.setProperty(prop, String(value));
}
});
return chain;
}

The undefined check is the one subtle part. It lets the caller conditionally skip elements without a second loop. Returning the old value would also work, but that extra write would fire mutation observers and is easy to get wrong for properties that have side effects.

Why set returns the chain

Every set branch ends with return chain. Without it, the second .css(...) in jq(el).css('color', 'red').css('opacity', 0.5) would run on undefined. Returning the same object keeps the API fluent and matches every other jQuery method.

A common mistake is to forget that arrays are also typeof 'object'. If the object-set branch runs before the array-get branch, css(['color']) would be treated as a set with { 0: 'color', length: 1 }. Either check the array case first, or use args[0].constructor === Object to match plain objects only.

Small extensions

  • Numeric units. Real jQuery appends px to numeric values for length properties like width, height, top, and margin-*. A maintained LENGTH_PROPS set and a small check in the set path covers this.
  • CamelCase property names. jQuery accepts backgroundColor as well as background-color. Convert with a small regex: prop.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()).
  • Removing a property. Passing an empty string should remove the inline style. The current code already does this because setProperty(prop, '') clears the property.