Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Component style exposure mechanism #8538

Open
Tyrenn opened this issue Apr 26, 2023 · 5 comments
Open

Component style exposure mechanism #8538

Tyrenn opened this issue Apr 26, 2023 · 5 comments
Labels
css Stuff related to Svelte's built-in CSS handling
Milestone

Comments

@Tyrenn
Copy link

Tyrenn commented Apr 26, 2023

Describe the problem context

I've been playing with Svelte for nearly a year now and it has been a major game changer for me. I'm absolutely in love with it 😍.
However, I'm still struggling when it comes to style generic components.

Long story short

Couple of month ago, I've started to build a component library for myself. Everything was going great until the question of styling. How do I allow future library users to style each component to benefit from mechanics but adapt them entirely to their application interface.
Styling is a big part of frontend development and so far, despite some funny stereotypes, I find CSS a pretty cool tool to achieve beautiful and intuitive interfaces. However, with svelte and scope-style, I can't use css out of the box to style the component wherever I use them. I have to either use javascript/typescript to expose style purpose variables, expose classes to do in DOM styling or break style scope (or multiply css variables in a specific and tricky way, I'll talk about it further as a workaround).

First, I really wish to keep styles scoped as much as possible. Second, exposing multiple css variables as component props is such a bad idea as it won't work without javascript, induces hard-to-read structure and is just unpleasant. So the only way to achieve components styling is to expose classes in a tailwind like way... Which kind of break style scoping, is unpleasant in my humble opinion and to summarize NOT CSS.

This is an issue that many svelte users are facing, and many ideas were proposed, mainly based on allowing component to be styled from outside. All these proposals were rejected, and rightly so. A list of some proposals and discussions is available below but I think that RFCs @pngwn comment beautifully summarizes maintainers opinion and the problems with the approaches so far proposed (I strongly suggest you to read it).

Long story LONG

I do understand the desire of maintainers to keep svelte opiniated to avoid misuse.

"Make the right thing easy, and the wrong thing possible." - RFCs @pngwn comment

Also, I share the vision that components should remain "their own bosses" meaning self-explainatory and aware of its exposed part. A component variable setting must be explicitly exposed by the component. This way, users can easily understand how the component works and how it should be used.

Therefore I completely agree with the negative response to the last feature requests regarding the opening of components to CSS class injection or similar concepts.

That said, I strongly disagree with the statement "component should not accept styles from outside". Waging war against parent styling children is waging war against theming and more generally genericity. Not having parent able to style components is drastically limiting component reuse which is one of the main advantage of Svelte and similar frameworks. CSS and styling of a component can't be isolated from context.

In the current state svelte does not allow to expose the styles of a component in a comfortable way. HTML structure can be exposed using slot API. JS code can be exposed using props. But CSS styles have no exposure mechanism; understand self-explainatory, explicit and comfortable mechanism.

Of course we can still expose styles by exposing props to use in style attribute. It works but it present major drawbacks :

  • it does not work without javascript,
  • it's big boilerplate,
  • it induces big component structure, multiplying props with a majority for styling purpose and not logic,
  • it makes reading of styles harder,
  • it implies working outside of the <style> tag whereas styling should just be modifiying <style> tag only.

Another way around is to use :global(). After reading many comments on the subject, at least everyone seems to agree that this is not a good and practical thing to do : This is equivalent to using global naming and does not take advantage of Svelte scoping. That's a problem everybody seems aware of.

"This API (component styling API) should not open the flood gates of global cascading CSS, and it should offer excellent ergonomics, preferably taking advantage of the recent language tooling developments. We need a theming solution." - @pngw

Related issues :

Related comments :


Describe the proposed solution concept

Inspired by @tncrazvan idea, multiple comments and my own researches, I here present what I think would be the ideal mechanism to adress those problems. Keep in mind that by "Ideal mechanism" I only suggest that it addresses the previously presented problems while respecting Svelte opiniated philosophy.
The main idea is to allow a component to describe explicitly and precisely through CSS which styles are exposed to parent(s).

In component.svelte

<div id="root" class="container">
	<span class="item">Hi</span>
	<span class="item everyone">Everyone !</span>
</div>

<style>

	#root{
		position : absolute;
		top : 50%;
		left : 50%;
		transform : translate(-50%,-50%);
	}

	span.item{
		font-family : Arial;
	}

// The css styles inside the :expose directive are exposed to parent styling

	:expose(span.item){
		color : red;
	}
	
	:expose(#root){
		width : 20vw; 
		height : 20vw;
	}
</style>

Note that we could also imagine styles completely exposed with a <style expose> tag in similar way of <style global>.

In parent.svelte

	<div id="parent">
		<Component>
	</div>

	<style>

		#parent div{
			width : 13vw;
			height : 13vw; // Overrides the root exposed styles
			position : fixed; // Is ignored as position is not exposed
		}

		// What's important is the selected DOM element not the selector
		#parent #root{
			left : 43%; // Still ignored
			height : 14vw; // Still overriding default 20vw
		}

		#parent span.item.everyone{
			color : blue; // Overriding the color just for the second span with everyone class
		}
	</style>

Of course the exposed/overrided styles would benefit from scoping and hash naming. I don't know exactly how to achieve that at compilation but I do understand that it requires additional and potentially complex logic. However I could imagine compilation to give something like below, with overriding based on CSS rules position.

	<div id="parent">
		<div id="root" class="container hash1 hashExposed1">
			<span class="item hash2">Hi</span>
			<span class="item everyone hash2 hashExposed2">Everyone !</span>
		</div>
	</div>

	<style>
		hash1{
			position : absolute;
			top : 50%;
			left : 50%;
			transform : translate(-50%,-50%);
			width : 20vw; 
			height : 20vw;
		}

		hash2{
			font-family : Arial;
			color : red;
		}

		hashExposed1 {
			width : 13vw;
			height : 14vw;
		}

		hashExposed2{
			color : blue;
		}
	</style>

Or we could imagine an even more precise overriding based on the css rules comparison, which would require even more complex compilation logic, but no pain no gain 😁

I would be happy to work on this more deeply when time comes, but I think this is sufficient to understand the concept of my proposal.

This solution would allow CSS exposition through CSS which make a lot of more sense than exposing CSS variables through props.


Describe the Alternative

Description

In this journey of svelte css styling, I gradually developed an alternative solution based on native css variables. Although it works in most use cases, it is quite heavy.
It looks more like a best practice guide.

"This -the solution- does not exist today. You can do an awful lot with css variables, more than people give them credit for, but this isn't a solved problem" - @pngw

Whenever I create a component destined to be used in multiple context with different styles, I use css native variables to expose the css property that I consider stylable. For each 'exposed' css property, I define 2 native variable, one to be used outside by parent and one default variable which can store default value (quite handy for theming).

It gives me a pretty simple component that I can style from parent with default styles depending on global template. Paired with exposed class, we can also define multiple default templates.

In component.svelte

<div id="root" component="component" class="container">
	<span class="item">Hi</span>
	<span class="item everyone">Everyone !</span>
</div>

<style>

	#root{
		position : var(--componentname-position, var(--default-componentname-position));
		top : var(--componentname-top, var(--default-componentname-top));
		left : var(--componentname-left, var(--default-componentname-left));
		transform : translate(-50%,-50%);
	}

	span.item{
		font-family : Arial;
	}
</style>

In a template global file template.css

div[component="component"]{
	--default-componentname-position : relative;
	--default-componentname-left : 50%;
	--default-componentname-right : 50%;
}

In parent.svelte

	<div id="parent">
		<Component>
	</div>

	<style>

		#parent{
			--componentname-top: 40%; // Overriding the default top value of component
		}
	</style>

This technique has numerous advantages :

  • uses native features,
  • do not break css scoping,
  • do not use :global() in most cases,
  • allows css rule inheritance,
  • only concerns CSS and <style> tag,
  • lets the component control its exposition,
  • offers new ways of defining theme (through default variables).

These are the main reasons that pushed me to introduce this solution as a pretty neat alternative to a css exposure mechanism. HOWEVER, some limitations remain (remember when I said "an almost complete solution" 😁)

Limitations

This alternative perfectly works when we use components inside native DOM elements but things get tricky when you wan't to use your stylable component inside other stylable component without having to add DOM elements.

Parent context

The first limitation is about parent context. If I use my component inside another and I want to apply a specific style depending on its parent context (focused, hovered or with a special class), I need to anticipate all of that inside the child component :

Say I have an Icon and a Button components.

Icon.svelte

<svg class="icon" ... />

<style>
	svg{
		stroke : var(--icon-stroke, var(--default-icon-stroke));
	}
</style>

Button.svelte

<div class="button">
<slot />
</div>

<style>
	.button{
		color : var(--button-color, var(--default-button-color));
	}

	.button:hover{
		color : var(--button-hover-color, var(--default-button-hover-color, var(--button-color), var(--default-button-color))) 
		// Note the ability to define fallbacks... 
	}
</style>

I can change the Button color depending on its context : hover or not. But if I wan't to use an Icon directly inside the Button, I can't change its stroke color depending on Button context.

anywhere

<div class="root">
	<Button>
		<Icon />
	</Button>
</div>

<style>
	.root{
		--button-color : white;
		--button-hover-color: grey;
		--icon-stroke : white;
	}
</style>

I actually found a solution which works for 1 level but kind of break the idea of component "independancy" and agnostic of its context :

Icon.svelte

<style>
	svg{
		stroke : var(--icon-stroke, var(--default-icon-stroke));
	}

	:global(*:hover) > svg{
		stroke : var(--icon-parent-hover-stroke, var(--default-icon-parent-hover-stroke));
	}
</style>

anywhere

<style>
	.root{
		--button-color : white;
		--button-hover-color: grey;
		--icon-stroke : white;
		--icon-parent-hover-stroke : grey;
	}
</style>

Note that it won't work as such if Icon is not a direct children of Button and also that this solution adds a ton of global css rules...

Component variants

Similarly, multiple questions arise when we wan't to use stylable components inside others without adding native DOM element. For example when I wan't to create a new stylable component based on a combination of others such as a Button variant with an Icon, several questions remain about how to treat their css variables :

IconButton.svelte

<Button>
	<Icon />
	<slot />
</Button>
  • Should I expose Icon prop class to be able to apply specific template to it ?
  • Should default styles of Icon written inside IconButton selector ?
  • Should I override Icon css variables ? By wrapping them.

Most of the time the answer depends on the usecase and induce tradeoffs.

Also, the idea of default styles works great for 1 level composition but gets harder when using mutliple ones...

This limitation in composition can be avoided with additional native html tag addition which is not catastrophic but not ideal either. It might also disappear with the apparition of :has() css selector.

Importance

would make my life easier. However, crucial to talk about at least.

Final words

I don't pretend to offer the perfect solution. But the mechanism I propose has the advantage of meeting a specification that respects the vision of the maintainers while enhancing developer experience :

  • Components control their exposition.
  • Styles are scoped.
  • CSS style has its own exposition API.

This mechanism would drastically enhances svelte potential by unblocking component genericity and, at the same time, reduces bad practices such as using :global().

Note that my goal is not to revive aggressive debates ! I do not have the energy to reply to aggressive thoughts. I'm not particularly attached to my proposal either, I just want to refocus debates on finding an acceptable solution to this aknowledged problem of styling... Maybe the solution relies in the alternative I've presented which I would happily discuss. I've opened a new issue as I wanted to regroup discussions about styling issues while providing a fresh start and stepping out of the aging huge general issue #6972. However, if the maintainers prefer, I can close it and change it as an answer.

@Tyrenn Tyrenn changed the title Component style exposure best practices and ability to use component tag as css selectors Component style exposure mechanism Apr 26, 2023
@robinloeffel
Copy link

robinloeffel commented May 23, 2023

I wholeheartedly agree with OP and all the other people that called for this feature. I'm absolutely loving Svelte for my pet projects, and I'm even trying to push it at work.

However, I have to say, that the complete lack of (scoped) style delegation feels like a huge oversight. As soon as your application reaches a certain size, where you might want to override a few properties of even something as basic as a <Button /> component, you'll have to resort to artificially bumping up your :global specificity or some other hacky workaround.

I hope we'll finally find a solution for this soon, otherwise Svelte will stay behind React and Vue in adoption at bigger companies.

@malammar
Copy link

malammar commented Sep 1, 2023

The solution is Tailwind. Every other dev I've talked to with a sufficiently complex Svelte app has dropped Svelte's broken <style> tag preprocessing completely and adopted a less ideologically stiff system like Tailwind or CSS modules.

@Tyrenn
Copy link
Author

Tyrenn commented Sep 2, 2023

Tailwind is a great tool especially to achieve fast styling, but in this context it is more a painkiller than a solution. Also it comes with 2 big counterparts that are a "no go" for me in a big project context :

  • Style in DOM
  • "Tailwind" kinda vibe in the final result

In fact Tailwind is not CSS. It is something different, a whole new syntax with different ways to achieve same goals as CSS. The mechanism I asked for should rely on pure CSS.

Plus, exposing the class prop is equivalent to using global css or rule. It does not complies with the whole "A component must be its own master" and the idea that a component should be aware of the style properties it exposes.

"This API (component styling API) should not open the flood gates of global cascading CSS, and it should offer excellent ergonomics, preferably taking advantage of the recent language tooling developments. We need a theming solution." - @pngwn

@FeldrinH
Copy link

FeldrinH commented Sep 2, 2023

In my opinion the best mechanism for theming/styling components by far is the Web Components part attribute and ::part selector (https://css-tricks.com/styling-in-the-shadow-dom-with-css-shadow-parts/). It's very flexible and powerful and retains the ergonomics of styling built-in HTML elements. Unfortunately it heavily depends on the Shadow DOM for isolating styles, but I think it is worth looking at as inspiration at least.

@Rich-Harris Rich-Harris added this to the 5.0 milestone Apr 3, 2024
@Rich-Harris Rich-Harris added the css Stuff related to Svelte's built-in CSS handling label Apr 3, 2024
@dummdidumm dummdidumm modified the milestones: 5.0, 5.x May 13, 2024
@AdaptingAFM
Copy link

AdaptingAFM commented Oct 7, 2024

I still think that the better syntax would be one pertained to the opinionated way of embedding/extending the functionalities of another module/component that the language already has, as I suggested on my comment that was quoted.

Having special default slots to bridge aspects others of composition than structure/templating, such as interactions and extensions of existent behavior/presentations, does not only feel the most natural to Svelte, but reincurs concerns and design goals to solutions and mechanisms already present within Svelte:

Ownership and control is a concern?
Parents (and their elements) could have some svelte-directives for fine granularity of the styles exposed to consumers.

Benefits I can see from this syntax

  • The locality of all use and extension of a component would be reduced to its invokation in a consumers' tree, preventing pollution and encapsulating all consumption/leveraging of a module to its mere invokation (to style a component wouldn't introduce expressions and declarations outside of its enclosing scope, in the consumer's style tag, as it would by this suggestion).
  • Language tooling would be trivial, and better of
  • The whole concept could also be translated to a special default slot for <script> tags, simplifying the whole process of extending a components' functionality via their exposed variables (extension of a components' functionality wouldn't have to exist within the script tag of the consumer)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
css Stuff related to Svelte's built-in CSS handling
Projects
None yet
Development

No branches or pull requests

7 participants