svelte icon indicating copy to clipboard operation
svelte copied to clipboard

Allow multiple classes in class: directive

Open Bastian opened this issue 3 years ago • 34 comments

Describe the problem

Utility-first CSS frameworks like Tailwind use very granular CSS classes (e.g. bg-red-500 for a red background, shadow-lg for a large box-shadow, ...). You often want to apply styles conditional with the class: directive. Unfortunately, it only works for a single class at the moment which means you have to duplicate it quite often. A very simple example for a Button component with Svelte and Tailwind might look like this:

<script lang="ts">
	export let color: 'primary' | 'danger' = 'primary';
</script>

<button
	on:click
	class="px-3 py-2 text-white rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
	class:bg-blue-700={color === 'primary'}
	class:hover:bg-blue-800={color === 'primary'}
	class:ring-blue-400={color === 'primary'}
	class:bg-red-600={color === 'danger'}
	class:hover:bg-red-700={color === 'danger'}
	class:ring-red-500={color === 'danger'}
>
	<slot />
</button>

image

This is very boiler-plate-heavy and annoying to work with. It's also just a very simple example for showcasing and usually gets even uglier in real-world examples. When using a utility-first framework you run into this issue a lot.

Describe the proposed solution

Allow the use of multiple CSS classes in the class: directive with a class:"x y z"={true} syntax. This would allow the example above to be simplified like this:

<script lang="ts">
	export let color: 'primary' | 'danger' = 'primary';
</script>

<button
	on:click
	class="px-3 py-2 text-white rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
	class:"bg-blue-700 hover:bg-blue-800 ring-blue-400"={color === 'primary'}
	class:"bg-red-600 hover:bg-red-700 ring-red-500"={color === 'danger'}
>
	<slot />
</button>

Alternatives considered

There's been a very similar issue (https://github.com/sveltejs/svelte/issues/3376) which unfortunately has been closed and not been re-opened despite getting a lot of follow-up comments that argue for its usefulness. In this issue, some alternatives have been discussed:

Using Tailwind's @apply directive

Tailwind does provide a @apply directive to extract multiple Tailwind-classes into a custom CSS class. For the example above, this could look like this:

<script lang="ts">
	export let color: 'primary' | 'danger' = 'primary';
</script>

<button
	on:click
	class="px-3 py-2 text-white rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
	class:primary={color === 'primary'}
	class:danger={color === 'danger'}
>
	<slot />
</button>

<style lang="postcss">
	.danger {
		@apply bg-red-600 hover:bg-red-700 ring-red-500;
	}

	.primary {
		@apply bg-blue-700 hover:bg-blue-600 ring-blue-400;
	}
</style>

While this appears to be a good solution (and is used by many to circumvent the issue), using the @apply directive goes against the utility-first workflow. Adam Wathan (the creator of Tailwind) advised against using it (Source):

Confession: The apply feature in Tailwind basically only exists to trick people who are put off by long lists of classes into trying the framework.

You should almost never use it 😬

Additionally, there are other Utility-CSS frameworks that usually don't have this feature.

Using the ternary operator

<script lang="ts">
	export let color: 'primary' | 'danger' = 'primary';
</script>

<button
	on:click
	class="px-3 py-2 text-white rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2
	{color === 'primary' ? 'bg-blue-700 hover:bg-blue-800 ring-blue-400' : ''}
	{color === 'danger' ? 'bg-red-600 hover:bg-red-700 ring-red-500' : ''}
	"
>
	<slot />
</button>

This does work, but obviously also introduces a lot of boilerplate code. The whole point of the class: directive is to eliminate this kind of code.

Writing a Svelte Preprocessor

I'm not familiar with preprocessors, but this has been a frequent suggestion in the original issue. There even exists one already: https://github.com/paulovieira/svelte-preprocess-class-directive

This might be a viable option but I would much rather prefer support out-of-the-box instead of relying on a third-party library. Besides not being actively maintained, the linked preprocessor uses an alternative, non-ideal syntax like described in the next section "Alternative syntaxes".

Alternative syntaxes

Many other syntaxes have been suggested, e.g. class:x,y,z={true), .x.y.z={true}, class:{"x y z"}={true}, ... The problem with most of them is that they either introduce breaking changes (e.g., class:x,y,z={true} is already valid syntax for the class x,y,z) and/or limit it to a sub-set of CSS-classes because , and . are valid characters in CSS class names. While not very common in "classic" CSS classes, they are often used by utility frameworks like Tailwind (e.g. gap-[2.75rem], grid-rows-[200px_minmax(900px,_1fr)_100px], or row-[span_16_/_span_16]). class:{"x y z"}={true} would work and should be supported as an alternative syntax (just like class={"x y z"} also works) but is also unnecessary (yet small) boilerplate in most cases.

Importance

would make my life easier

Final words

As mentioned above, this is technically a duplicate of https://github.com/sveltejs/svelte/issues/3376. However, since there have been no responses from any maintainers (even when pinging them) on the original issue, I've decided to open this issue with a summary of the discussion in the original issue. I would very much appreciate a re-evaluation of the original decision to not support this feature, either in this issue or by re-opening the original one. Thank you for the awesome work on Svelte!

Bastian avatar Jan 21 '22 11:01 Bastian

I need this in my life.

irishburlybear avatar May 21 '22 13:05 irishburlybear

cool

Conduitry avatar May 21 '22 16:05 Conduitry

Current alternative to using the tenary operator would be to use an action like the following. It's more or less the same count of characters but can be in some circumstances be more verbose. REPL

  function clazz(node, props) {
   for (let prop of props) {
      if (prop[0]) {
         node.classList.add(...prop[1].split(" "));
      }
   }

   return {
      update(props) {
         for (let prop of props) {
            if (prop[0]) {
               node.classList.add(...prop[1].split(" "));
            } else {
               node.classList.remove(...prop[1].split(" "));
            }
         }
      },
   };
  }
<button
	on:click
	class="px-3 py-2 text-white rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
	use:clazz={[
				[color === 'primary', "bg-blue-700 hover:bg-blue-800 ring-blue-400"],
				[color === 'danger', "bg-red-600 hover:bg-red-700 ring-red-500"]
				]}
>
	<slot />
</button>

tobiaskohlbau avatar Jun 11 '22 10:06 tobiaskohlbau

Hey! If anyone wants to use this feature here is my vite plugin

npm i svelte-multicssclass

update your vite.config

// vite.config.js

import { sveltekit } from '@sveltejs/kit/vite';
import { multicssclass } from 'svelte-multicssclass';

/** @type {import('vite').UserConfig} */
const config = {
  plugins: [multicssclass(), sveltekit()],
};

export default config;

before:

<label
  class:text-gray-500="{isValid}"
  class:bg-gray-50="{isValid}"
  class:border-gray-300="{isValid}"
  class:text-red-700="{!isValid}"
  class:bg-red-50="{!isValid}"
  class:border-red-300="{!isValid}"
>
  text
</label>
usage:
  - choose a separator char ;  ,  | or configure your own multicssclass({ sep: '@' })
  - write your classes using the sep 
      <element class:class1;class2;class3={condition} />
      Custom sep
     <element class:class1@class2@class3={condition} />
  - two separators for toggle 
      <element class:true-class1;true-class2;;false-class1;false-class2={condition} />
      Custom sep 
      <element class:true-class1@true-class2@@false-class1@false-class2={condition} />

after:

<label
  class:text-gray-500;bg-gray-50;border-gray-300;;text-red-700;bg-red-50;border-red-300="{isValid}"
>
  text
</label>

<!-- OR -->

<label
  class:text-gray-500,bg-gray-50,border-gray-300,,text-red-700,bg-red-50,border-red-300="{isValid}"
>
  text
</label>

<!-- OR -->

<label
  class:text-gray-500|bg-gray-50|border-gray-300||text-red-700|bg-red-50|border-red-300="{isValid}"
>
  text
</label>

enjoy

🌌

fernandolguevara avatar Aug 17 '22 20:08 fernandolguevara

Maybe a simple option to pass an array into the class directive.

class:[class1,class2]={someBooleanVariable}

Where class1 and class2 would be applied if someBooleanVariable is true.

bwklein avatar Apr 12 '23 19:04 bwklein

I really like the proposed syntax

<script lang="ts">
	export let color: 'primary' | 'danger' = 'primary';
</script>

<button
	on:click
	class="px-3 py-2 text-white rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
	class:"bg-blue-700 hover:bg-blue-800 ring-blue-400"={color === 'primary'}
	class:"bg-red-600 hover:bg-red-700 ring-red-500"={color === 'danger'}
>
	<slot />
</button>

Because https://github.com/tailwindlabs/prettier-plugin-tailwindcss and https://github.com/tailwindlabs/tailwindcss-intellisense will easily adapt to it too.

rynz avatar Sep 16 '23 20:09 rynz

I like this syntax and wholeheartedly support this proposal.

NonVideri avatar Sep 17 '23 11:09 NonVideri

What's your thoughts @Rich-Harris and @adamwathan?

rynz avatar Sep 19 '23 02:09 rynz

        export let button_type;
	const cls = {
		default: 'px-3 py-2 text-white rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2',
		primary: 'bg-blue-500 hover:bg-blue-600 focus:ring-blue-500 focus:ring-offset-blue-200',
		danger: 'bg-red-500 hover:bg-red-600 focus:ring-red-500 focus:ring-offset-red-200'
	};
	const style = cls.default + ' ' + cls[button_type];

Im against adding new syntax to svelte when it can be replicated in simple js. And additionally there are far more suited libraries to handle this type of thing like class-variance-authority

xpertekShaun avatar Sep 19 '23 04:09 xpertekShaun

Svelte should have any solution for that. Adding a lost class statement should be achievable in easier mode: array or something.

dawidmachon avatar Sep 19 '23 21:09 dawidmachon

@Rich-Harris It would be fantastic if v5 include this feature.

harryqt avatar Dec 11 '23 09:12 harryqt

Honestly I just settled for clsx

sanfilippopablo avatar Mar 23 '24 16:03 sanfilippopablo

Honestly I just settled for clsx

Interesting, could you provide a code example on how you do this?

nonameolsson avatar Mar 25 '24 06:03 nonameolsson

Yes! With clsx the example at the top of this issue could be expressed as:

<script lang="ts">
  import clsx from "clsx";
  export let color: 'primary' | 'danger' = 'primary';
</script>

<button
  on:click
  class={clsx("px-3 py-2 text-white rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2", {
    "bg-blue-700 hover:bg-blue-800 ring-blue-400": color === 'primary',
    "bg-red-600 hover:bg-red-700 ring-red-500": color === 'danger'
  })}
>
  <slot />
</button>

sanfilippopablo avatar Mar 25 '24 12:03 sanfilippopablo

@dummdidumm Do you think this can be added to v5 milestone?

TKDev7 avatar Mar 29 '24 06:03 TKDev7

I've added it to the milestone, which isn't a commitment to do it for 5.0, but means it will be considered so that we don't miss the window provided by the semver major.

Rich-Harris avatar Apr 01 '24 02:04 Rich-Harris

Idea: Right now the " is an illegal character. We can use this to our advantage to introduce multiple classes and class names with weird characters like this:

  • weird character inside -> use quotes: class:"a/b"={..}
  • multiple classes -spaces between each class: class:"a b/x c"={...} - we're taking advantage of the fact that spaces always separate classes
  • in case the quote is needed for the class name (not sure if it's even possible) you can escape it like \"

Since quotes are illegal right now, this could be done in a minor later on.

dummdidumm avatar Apr 06 '24 13:04 dummdidumm

I have to admit the class:"quoted"={value} syntax really irks me — it feels like a real anomaly, and makes me wonder why I can't do things like this:

<div class:"foo {bar} baz"={value}>...</div>

Is the {bar} supposed to be treated literally? Or should it be interpolated? Either answer would be extremely strange.

An alternative could be to introduce a classes directive, with classnames separated by commas or pipes:

<div classes:a,b,c={value}>...</div>
<div classes:a|b|c={value}>...</div>

As far as I'm aware neither character is used in standard Tailwind. The comma is probably the better choice since | has an existing meaning with directives.

I'm not too worried about accommodating weird characters — as long as there's an escape hatch (which there is) then we don't need to optimise for edge cases.

Rich-Harris avatar Apr 21 '24 16:04 Rich-Harris

@Rich-Harris could we use the same format and just use class: and it is assumed to be a 1...n array of classes? Then we don't need a new parameter and it would be backwards compatible.

I don't think the plural form adds much for comprehension of the purpose and use.

bwklein avatar Apr 21 '24 16:04 bwklein

I agree with @Rich-Harris and @bwklein.

<div class:a,b,c={value}>...</div>

HTML doesn't have/need a plural form for multi-class either so I don't think anyone would be upset if they had the option to append additional class names with a , in the same way they can do it in HTML with a space.

rynz avatar Apr 21 '24 21:04 rynz

Perhaps a straw-man here, but why not just build something like clsx into the class attribute? Something like:

<button class={[
  'btn',
  {
    'btn-primary': isPrimary,
    'btn-link': isLink,
  }
]}>
  <slot />
</button>

orbiteleven avatar Apr 22 '24 06:04 orbiteleven

Perhaps a straw-man here, but why not just build something like clsx into the class attribute? Something like:

<button class={[
  'btn',
  {
    'btn-primary': isPrimary,
    'btn-link': isLink,
  }
]}>
  <slot />
</button>

This would be amazing!!!

frederikhors avatar Apr 22 '24 07:04 frederikhors

I have to admit the class:"quoted"={value} syntax really irks me — it feels like a real anomaly, and makes me wonder why I can't do things like this:

<div class:"foo {bar} baz"={value}>...</div>

I mean.. we could allow that to be a dynamic expression, couldn't we? What else irks you / makes it feel like an anomaly? Because it's just the class string syntax, just before the equals sign.

Comma-based solutions make the whole thing feel crammed (not breathing room between classes) and I fear tailwind's micro syntax might grab this character at some point, too.

dummdidumm avatar Apr 22 '24 07:04 dummdidumm

I fear tailwind's micro syntax might grab this character at some point, too

https://play.tailwindcss.com/a6j1Ed7RF2

<div class="m-10 [box-shadow:_0_10px_red,_0_-10px_blue] *:before:content-['_|_']">
  <div>A</div>
  <div>B</div>
</div>

Tailwind has had the ability to use CSS inside its classes for quite some time, where |, ,, ' and other characters are allowed.

Serator avatar Apr 22 '24 07:04 Serator

As far as I'm aware neither character is used in standard Tailwind.

Both can be used in Tailwind using their arbitrary values feature, and at least the , isn't that uncommon, e.g. for grids like grid-rows-[200px_minmax(900px,_1fr)_100px]. But Tailwind isn't the only CSS framework out there, and making assumptions about which characters are "weird" enough to not support them feels really wrong - and could lead to problems in the future for frameworks that may not even exist today.

A space character is the logical choice in my opinion. It's already used in normal HTML to separate multiple classes, and I assume it's easier for most third-party tools (formatters, linters, syntax highlighters, etc.) to adapt as well.

Bastian avatar Apr 22 '24 07:04 Bastian

To add to https://github.com/sveltejs/svelte/issues/7170#issuecomment-2068640727: #7294 asks for dynamic conditional classes, so just allowing expressions would solve this request, too

dummdidumm avatar Apr 23 '24 12:04 dummdidumm

I really like the Angular approach here where you can just do this:

class="w-1/5"
class={
        'md:w-10/12 lg:w-8/12 2xl:w-6/12':
            someVar
        'lg:w-10/12':
            anotherVar
        '2xl:w-8/12':
            yetAnotherVar
    }

The only "problem" i see is that currently in svelte you cannot combine a class with a dynamic class because attributes need to be unique (might be different in Svelte 5?)

thebspin avatar May 01 '24 12:05 thebspin

Why not just use class (attribute) syntax with directives? Either make quotes mandatory or optional.

Based on the example above:

class="w-1/5"
class:"md:w-10/12 lg:w-8/12 2xl:w-6/12"={someVar}
class:lg:w-10/12={anotherVar}
class:2xl:w-8/12={yetAnotherVar}

class:"..." will allow the use of spaces, which is a native separator for classes. In the case of a single class, the quotes could be omitted, as is done now.

Serator avatar May 01 '24 13:05 Serator

Why not just use class (attribute) syntax with directives? Either make quotes mandatory or optional.

Based on the example above:

class="w-1/5"
class:"md:w-10/12 lg:w-8/12 2xl:w-6/12"={someVar}
class:lg:w-10/12={anotherVar}
class:2xl:w-8/12={yetAnotherVar}

class:"..." will allow the use of spaces, which is a native separator for classes. In the case of a single class, the quotes could be omitted, as is done now.

I like this option a lot as well.

thebspin avatar May 01 '24 14:05 thebspin