0

Abstract

I'm trying to figure out a way to use JavaScript to dynamically create CSS rules on a page, modify them, and disable them. The rules would come in "packages" with an ID and a group of one or more CSS rules which may overlap.

Example and Data

/*ID: something*/
div#foobar {display:none;}

/*ID: blah*/
input {background:red;}
div.password {width:100px;}

/*ID: test*/
div.password {width:200px;}

The rules are in an associative-array that contains the data, such as:

myrules["something"] = "div#foobar {display:none;}";
myrules["blah"]      = "div.password {width:100px;} input {background:red;}";
myrules["test"]      = "div.password {width:200px;}";

"Question"

Now I need a way to add the defined rules to the page with a way to toggle them using the IDs.

Requirements

The main issues the current attempts (below) have run into are:

  • Adding CSS rules via CSS syntax (ie, not JavaScript object names, so background-color, not .backgroundColor)
  • Being able to enable and disable the rules
  • Being able to access the rules via a string ID, not a numeric index
  • The rules should be bunched in a single stylesheet object or element since there can be HUNDREDS of packages, so creating a separate stylesheet element for each would be impractical
  • Multiple "packages" can be active at once, it's like a buffet of styles to apply to the page, not just picking a single one
  • This has to work purely through modifying styles, the page itself can't be modified since there are many elements and it's even less practical to add or remove class tags to all of the elements

Attempts (what I've already tried)

I've looked at several different ways from document.styleSheets to .sheet.cssRules[], from .innerHTML to insertRule(). I've become dizzy from trying to figure out what's what; it's such a quagmire, with poor examples. Sometimes I manage to use one technique to accomplish one aspect of it, but then another aspect won't work. I can't find a solution that satisfies all of the aforementioned requirements.

And searches are difficult because of the ambiguous nature of phrasing leading to incorrect search-results.



Surely there has to be an efficient way to do this, right? 🤨

6
  • 1
    creating a separate stylesheet element for each would be impractical — how do you know? That's obviously the simplest thing to do: include the <style> tags with id attributes, and then you can find them, remove them, and re-add them to the DOM as needed. You can fill <style> tags with content using .innerHTML or .innerText, I can't remember which but that has worked for 20 years or so in my experience. Commented Oct 25, 2024 at 17:45
  • 1
    You could also look at the features of CSS which could make this easier. Making them all dependent on an ancestor's class might be one way. (e.g. .something { div#foobar {display:none;} } .blah { input {background:red;} div.password {width:100px;} } .test { div.password {width:200px;} } Then, with JS, all you'd have to do is change the ancestor's class to see the change: document.body.classList.add('something'); document.body.classList.remove('blah','test'); Commented Oct 25, 2024 at 18:59
  • Like @HereticMonkey said, using an ancestor class is widly used for dark/light mode or multiple theming. And it can be extended to more use case. The no-hassle solution I would recommand. Commented Oct 26, 2024 at 6:44
  • I've been looking at this question on-and-off for quite a while, and I'm still not sure exactly what you're asking for (this is very much on me, I think); my best guess is this: jsfiddle.net/davidThomas/v4kzLqoh but I'm really not sure. Commented Oct 26, 2024 at 15:44
  • @DavidThomas You should be a teacher because your not-being-sure is correct, your example is exactly what I was looking for. Post it as an answer so I can accept it. 👍 Commented Oct 27, 2024 at 13:06

3 Answers 3

1

One further approach is below, with explanatory comments in the code:

// using an arrow function modifyStyles() to handle the application and
// removal of the user-selected styles, this function takes one argument
// a reference to the Event Object (passed automagically from the
// EventTarget.addEventListener() call, later):
const modifyStyles = (evt) => {
  // destructuring assignment, which takes the 'target' property
  // from the Event Object, and then assigns the value of that
  // property to the 'btn' variable (this is a personal preference
  // and helps me to keep track of what interaction this function
  // handles):
  let {
    target: btn
  } = evt,
  // here we use document.querySelector() to retrieve a specific element
  // using a CSS selector, if that returns a falsey result then instead
  // we create that <style> element and assign that created element to
  // to the 'style' variable:
  style =
    document.querySelector("#user-custom-css-content") ||
    document.createElement("style");

  // we toggle the 'active' class on the clicked <button> element, if the
  // class is already present it will be removed, if not present it will
  // be added:
  btn.classList.toggle("active");

  // here we retrieve closest ancestor '.wrapper' element to the clicked
  // <button>, and from their retrieve all '.active' elements; we use the
  // spread (...) operator and Array literal to convert the returned
  // NodeList to an Array:
  let rules = [...btn.closest(".wrapper").querySelectorAll(".active")]
    // we then use Array.prototype.map() to iterate over the Array
    // of Element Nodes and create a new Array based on those Nodes:
    .map(
      // here we retrueve the value of the data-style custom attribute
      // from the elements, and use that value as the property-name of
      // the cssRules object (defined below), creating an Array of
      // CSS rules:
      (el) => cssRules[el.dataset.style]
    )

  // then set the text-content of the <style> element to be a string
  // with each Array element being joined together by a new-line
  // character:
  style.textContent = rules.join("\n");

  // a variable to determine if the <style> element has content,
  // if all <button> elements are deselected and none have the
  // 'active' class there will be no content in the Array that
  // was joined; here we test that by assessing whether a the
  // length of the text-content with leading and trailing
  // white-space removed is greater than zero, this returns a
  // Boolean true/false:
  let hasContent = 0 < style.textContent.trim().length;

  // here we check to see if the id of the <style> Object is
  // NOT equal to the string, and that the <style> has content:
  if ("user-custom-css-content" !== style.id && hasContent) {
    // if the id doesn't match, we set the id:
    style.id = "user-custom-css-content"
    // and if there is content we append the <style> to the
    // <head> of the document:
    document.head.append(style);

    // otherwise if there is no content:
  } else if (false === hasContent) {
    // we use Element.remove() to remove the empty <style>
    // element:
    style.remove();
  }
}

// a sample of potential CSS rules contained within an Object:
const cssRules = {
  something: "h1, .item:nth-child(odd) { opacity: 0.5; } figcaption { text-decoration: underline; }",
  blah: "h1 { background: linear-gradient(90deg, transparent, cyan); }",
  test: "img + * { color: rebeccapurple; font-weight: 100;} li:nth-child(even of .item) {border-inline-start: 2px solid fuchsia; padding-inline-start: 1rem; } img { clip-path: polygon(50% 0%, 80% 10%, 100% 35%, 100% 70%, 80% 90%, 50% 100%, 20% 90%, 0% 70%, 0% 35%, 20% 10%);}",
}

// retrieving a NodeList of <button> elements with the data-style custom
// attribute:
const buttons = document.querySelectorAll("button[data-style]")

// iterating over that NodeList - using NodeList.prototype.forEach():
buttons.forEach(
  // binding the modifyStyles() function as the handler for
  // the 'click' event on the <button> elements:
  (btn) => btn.addEventListener("click", modifyStyles)
)
@layer base {
  *,
   ::before,
   ::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }
  html {
    block-size: 100%;
  }
  body {
    block-size: 100dvh;
    padding-block: 0.5rem;
    padding-inline: 1rem;
  }
  main {
    border: 1px solid;
    inline-size: clamp(20em, 80%, 1000px);
    margin-inline: auto;
    padding: 0.5rem;
  }
  ul,
  ol,
  li {
    list-style-type: none;
  }
  section {
    display: grid;
    gap: 0.5rem;
    grid-template-columns: [full-start text-start] 2fr [text-end fig-start] 1fr [fig-end full-end];
  }
  h1,
  footer {
    grid-column: full;
  }
  .spiel,
  ul {
    grid-column: text;
  }
  ul {
    column-count: 2;
  }
  figure {
    grid-column: fig;
    grid-row: 2 / span 2;
    img {
      clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
      transition: clip-path 2s linear;
    }
  }
  footer {
    .controls {
      display: flex;
      flex-flow: row nowrap;
      gap: 1rem;
      justify-content: space-between;
      &>* {
        flex: 1 0 auto;
      }
      button {
        background: var(--active-color, lightgrey);
        border: 1px solid;
        border-radius: 10rem;
        inline-size: 100%;
        &.active {
          --active-color: lightcyan;
        }
      }
      summary {
        display: block;
        font-size: 1rem;
      }
    }
  }
}
<!-- generic HTML laid out as a common card component: -->
<main>
  <section>
    <h1>Arbitrary title!</h1>
    <p class="spiel">
      Lorem ipsum dolor sit amet, consectetur adipisicing elit. Aliquam voluptate totam placeat. Call to action, so on and so forth: do the thing!
    </p>
    <ul>
      <li class="item item-1">List item 1</li>
      <li class="item item-2">List item 2</li>
      <li class="item item-3">List item 3</li>
      <li class="item item-4">List item 4</li>
      <li class="item item-5">List item 5</li>
      <li class="item item-6">List item 6</li>
      <li class="item item-7">List item 7</li>
      <li class="item item-8">List item 8</li>
      <li class="item item-9">List item 9</li>
      <li class="item item-10">List item 10</li>
      <li class="item item-11">List item 11</li>
      <li class="item item-12">List item 12</li>
    </ul>
    <figure>
      <img src="//picsum.photos/id/20/300" alt="" />
      <figcaption>Text related to the featured (demo) image.</figcaption>
    </figure>
    <footer>
      <ul class="controls wrapper">
        <li>
          <!-- using custom data-style attributes to store the name of
               the cssRules Object property that contains the custom 
               rules to apply: -->
          <button type="button" id="style-1" class="style-option style-1" data-style="something">
            Style 1
          </button>
        </li>
        <li>
          <button type="button" id="style-2" class="style-option style-2" data-style="blah">
            Style 2
          </button>
        </li>
        <li>
          <button type="button" id="style-3" class="style-option style-3" data-style="test">
            Style 3
          </button>
        </li>
      </ul>
    </footer>
  </section>
</main>

JS Fiddle demo.

References:

Sign up to request clarification or add additional context in comments.

Comments

1

You could create a <style> element inside the <head> tag and change its content as needed. There may be other ways to do it but, if I understand your requirements correctly, below example may help:

// Your CSS rules
let myRules = {
  something: "div#foobar {display:none;}",
  blah: "div.password {width:100px;} input {background:red;}",
  test: "div.password {width:200px;}"
}

// Create the style element
let style = document.createElement("style")
// Give it a unique id in case there are other style elements
style.id = "myStyle"
// Append the created style element to <head>
document.head.appendChild(style)

// Example function to change the contents of the new style tag by passing the needed style values as arguments
function changeStyle(...args) {
  const myStyle = document.head.querySelector('style#myStyle')
  
  myStyle.innerText = args
}

document.getElementById("changeStyleButton1").addEventListener('click', () => {
  changeStyle(myRules["test"])
})

document.getElementById("changeStyleButton2").addEventListener('click', () => {
  changeStyle(myRules["blah"])
})

document.getElementById("changeStyleButton3").addEventListener('click', () => {
  changeStyle(myRules["something"] + myRules["test"])
})

changeStyle(myRules["something"] + myRules["blah"])
<html>
<head>
</head>
<body>

<div id="foobar">foobar div</div>
<div class="password">
  <input type="password">
</div>
<p>
<button id="changeStyleButton1">Change Style 1</button>
<button id="changeStyleButton2">Change Style 2</button>
<button id="changeStyleButton3">Change Style 2</button>
</body>
</html>

UPDATE

After reading the comments, looks like I didn't fully understand the OP's requirements. If a toggle is needed for each style in the rules set, then following could be an alternative to the accepted answer:

// Your CSS rules
const myRules = {
  something: 'div#foobar {display:none;}',
  blah: 'input {background:red;}',
  test: 'input {border:3px solid green;}'
}

// Generate buttons dynamically based on css rules data (optional, can be hard coded if needed)
Object.keys(myRules).forEach((key) => {
  let button = document.createElement('button')

  button.dataset.alt = 'style'
  button.dataset.style = key
  button.innerHTML = `Apply "${key}"`
  button.onclick = (e) => {
    applyStyles(e)
  }

  document.getElementById('styleButtons').appendChild(button)
})

// Add new style element to document head with unique id
const myStyle = document.createElement('style')
myStyle.id = 'myStyle'
document.head.appendChild(myStyle)

function applyStyles(e) {
  // Toggle the 'active' css class of clicked button
  e.target.classList.toggle('active')

  // Get all the buttons
  const buttons = document.querySelectorAll('[data-alt]')

  // Start with empty stylesheet
  let styles = ''

  // Add the style to the stylesheet if button has 'active' class
  buttons.forEach((button) => {
    if (button.classList.contains('active')) {
      styles += myRules[button.dataset.style]
    }
  })

  // Update the stylesheet with new data
  document.head.querySelector('#myStyle').innerHTML = styles
}
.active {
  background: orange;
 }
<html>
<head>
</head>
<body>

<div id="foobar">foobar div</div>
<div class="password">
  <input type="password">
</div>
<p>
<div id="styleButtons"></div>
</body>
</html>

3 Comments

That won't work. Your solution will only provide for a single package to be active at once. I thought I was clear about toggling them, but I guess I should update the question to clarify that.
The last example compiles multiple css rules: changeStyle(myRules["something"] + myRules["blah"]) (and button3). So it should actually suit your needs.
Yes, but only one of the buttons is active at once because changing .innerText completely replaces its contents
0

It really depends on how you want to manage the CSS rules.

Here's an example (see the toggleRule function):

const allRules =
{
  'myCustomRule1': '#CustomSection .CustomTitle { text-decoration: underline; color: red; }',
  'myCustomRule2': '#CustomSection .CustomTitle { text-decoration: overline; color: green; }',
  'myButtonsRule': 'button { font-size: 16px; color: blue; background-color: #FFF; }',
};

const myRules = document.createElement('style');
{
  myRules.type = 'text/css';
  myRules.id = 'myRules';
  
  document.head.appendChild(myRules);
}

function toggleRule ( ruleText )
{
  const selectorText =
  (
    ruleText.substring(0, ruleText.indexOf('{')).trim()
  );
  let found = false;
  
  for ( const sheet of document.styleSheets )
  {
    for ( const ruleIndex in sheet.cssRules )
    {
      const rule = sheet.cssRules[ruleIndex];
      
      if ( ( rule.cssText == ruleText ) || ( rule.selectorText == selectorText ) )
      {
        found = true;
        sheet.deleteRule(ruleIndex);
      }
    }
  }
  
  if ( ! found )
  {
    const sheet = Array.from(document.styleSheets).at(-1);
    
    sheet.insertRule(ruleText, sheet.cssRules.length);
  }
}

///// TESTS \\\\\

document.querySelector('#buttonRule1').addEventListener
(
  'click', clickEvent =>
  {
    toggleRule(allRules.myCustomRule1);
  }
);

document.querySelector('#buttonRule2').addEventListener
(
  'click', clickEvent =>
  {
    toggleRule(allRules['myCustomRule2']);
  }
);


document.querySelector('#allButtonsRule').addEventListener
(
  'click', clickEvent =>
  {
    toggleRule(allRules.myButtonsRule);
  }
);
#CustomSection
{
  background-color: #EEE;
  margin: 0;
  padding: 5px;
}

#CustomSection span
{
  font-size: 18px;
}

#CustomSection .CustomTitle
{
  color: #00C;
}
<div id="CustomSection">
  Click a button to <span class="CustomTitle">Toggle Class</span>
</div>
<button id="buttonRule1">Click to toggle rule #1</button>
<button id="buttonRule2">Click to toggle rule #2</button>
<button id="allButtonsRule">Click to toggle rule for all buttons</button>

The function basically verify if there are any rules that match the given text. If they match, then the rule is excluded. If there is no matching rule, then the rule is inserted in the last style sheet (which should be the appended myRules). Which should make the rule override any other rule set before.

3 Comments

I tried expanding this with a few more examples, and there are two problems: (1) the rules must be very specifically-formatted (eg adding or removing any spaces will prevent it from working), (2) it only allows for a single rule per entry (eg myCustomRule sets an underline, but you can't add anything else, like a color.
@Synetech I think the problem is fixed now.
I clicked the buttons a few times and it broke with the error "TypeError: rule is undefined" 🤔

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.