Closed sastan closed 3 years ago
Looks like a good approach, and compose sounds like a reasonable name!
Something to think about is offering a way to customize the generate class names, e.g. prepending a string or running some kind of transformation function. Styled components has a handy babel plugin that automatically prepends the component name and/or file name before generated hashes so you can easily debug styles. I don't think twind could achieve this same experience without Babel, but in the meantime a way to manually add this info might be useful for debugging, e.g.
compose('PurpleButton')`py-4 px-8 rounded-lg bg-purple-600`
// -> .PurpleButton-XXXX
Emotion uses labels for that.
let style = css`
color: hotpink;
label: some-name;
`
If twind would support labels, or className prefixes, we could do something similar. That way it could even work when adding labels inside the className with the shim.
pseudo:
<button class="label:PurpleButton py-4 px-8 />
And
compose`
label:PurpleButton
py-4 px-8
rounded-lg
bg-purple-600`
// -> .PurpleButton-XXXX
The separator could be either a :
(label:x) to be like emotion, or a -
(label-x) to be more tailwind like.
I like the label idea and would move that to separate issue.
The one thing I'm hesitant about is adding more complexity to the public interface. This proposal might raise more questions around "when I should use tw
vs. when I should use compose
", and so on.
Is there a more direct solution to making overrides more reliable? I'd prefer that. I think the current composition model is lovely, it's just that the lib's approach of outputting tailwind class names is at odds with the cascade. That's one of the big wins to using Emotion and others, that you pretty much never have to worry about correctly applied overrides.
One naïve idea that comes to mind, is generating extra CSS classes with a suffix like -override-x
each time a specificity override is needed to apply the right class:
tw`text-blue-500 text-red-500`
// outputs text-blue-500 text-red-500
tw`text-red-500 text-blue-500`
// outputs text-red-500 text-blue-500-override-1
I have a feeling this would be a pain to make work 🙃 just throwing things out there
Reading this makes me wonder whether or not we can merge functionality of tw and compose to avoid adding additional API-surface, users could get confused about when to use what and a huge selling point here is that 'you just need tw to start'.
My thinking would be that when we invoke tw and encounter an existing class we could consider that as the base and the added ones as extensions on the base.
const base = tw'bg-blue'
const extended = tw`${base} bg-red`
So this would be one approach, this would enforce the user for generic components to stringify base and add additional classes as a prop.
Another option would be to consider the lexical ordering, so when we encounter bg twice in the same interpolation we could forget about the first encounter or make the second one !important
.
I personally think the combination of the above two options could turn out pretty strong.
Just throwing some idea's out there, I do think the idea in itself is sound but I'd look for options not to introduce more API surface.
Do note that this limitation also currently exists in Tailwind and often gets the advise of making the 'stable' overridable styles as props for instance a button flavor,...
I agree it would be super nice if we can have these two concepts in one (tw
and compose
) as it seems it'll almost certainly lead to confusion around which one is the right one.
There is a piece I'm missing, too, though...
Keep in mind im using web components, if someone does this:
html`<div class=${tw`bg-blue`}></div>`; // results in class="bg-blue"
const base = tw`bg-blue`;
html`<div class=${tw`${base} bg-red`}></div>`; // results in class="bg-blue bg-red"?
surely the simplest route would be to follow order, and to know bg-red
naturally overrides bg-blue
. so we'd just ignore bg-blue
.
so your internal implementation of tw
could do a merge of two object representations of these styles, but the result of tw
would be the end object mapped back to tailwind classes.
also, in tailwind itself if you had class="bg-blue bg-red"
, what does it do? @JoviDeCroock is this what you meant when you said they already have this limitation?
maybe im just missing something or skipped an example
@43081j in the current implementation the order of which the tailwind
css-stylesheet creates this classes will determine what background will be shown.
.bg-blue {
background-color: blue;
}
.bg-red {
background-color: red;
}
.bg-red {
background-color: red;
}
.bg-blue {
background-color: blue;
}
The above two stylesheets will lead to different results when it comes to which class gets applied to the dom-element.
I could be mixing it up but for <div className="bg-blue bg-red />
in the first sheet it will be blue and in the second it will be red, depending on which of these two classes gets seen first.
This is a limitation within css since there's no inspection what a class actually does, in JS-land this should be solvable.
I understand that part, but to solve that could we not just deal with overrides when we call tw
?
for example:
tw`bg-blue bg-red`; // bg-red
tw`${tw`bg-blue`} bg-red`; // bg-red
tw`bg-blue`; // bg-blue
as it doesn't seem "normal" that anyone would ever purposely want two conflicting classes.
i assume the only reason anyone would ever have conflicting class names would be if they're trying to compose or have done it by accident. in which case, we could just de-dupe for them and drop the overridden classes like above.
this way the CSS would be what it is now, and would only ever contain the tailwind classes (not our own extra 'generated classes').
right now, twind pretty much seems to output the same css as is inside tailwind itself. but if we do this proposal, that wouldn't be true anymore as it would also contain a bunch of our own generated classes.
Yes, your above comment is a good elaboration on
often gets the advise of making the 'stable' overridable styles as props for instance a button flavor,...
You'll often see the pattern return where for instance an extendable style has this property called flavor.
<Button flavor="primary" />
Which will determine these overridable attributes, there are often special cases though when it comes to margins/paddings/.... so I can see the merit there, ran into a few of those myself where a composable component would need a bit more margin, ....
that could still be solved by de-duping conflicting classes though, right?
just wondering if we don't need to go as far as producing our own CSS, and can instead control the class names more to ensure conflicting ones are removed.
again, i could be missing something though. if there's a case where that wouldn't work, it'd be useful to know
Well it depends, right? You would need to be able to inspect classes to see what it contains which is a lot more complicated than it sounds.
const base = tw`bg-blue-500`
const ex = tw`${base} bg-red-500`
This needs a reverse-translation step to see what base
contains, which is a lot of effort for the merit it actually has.
Not having to generate CSS-in-JS objects in order to compose (i.e. just class name inspection) is desirable and have considered the possibility before, however I get stumped at this cases like this:
const base = tw`text-center text-xl`
const ex = tw`${base} text-sm`
In cases like @JoviDeCroock posted above it might seem trivial to dedupe the class names based on the common bg-
prefix, you could say something like the last bg-
directive wins.. the problem arises when the starting parts of each directive in the composed class are common.
Once both these rule sets have been compiled (and if they were stored internally as CSS-in-JS objects) they they are easy to compare and compose:
const composedStyles = {
...({ textAlign: 'center', fontSize: '2rem' }),
...({ fontSize: '1rem' })
}
By nature of object extension the latter fontSize
will win. I think this is what @JoviDeCroock is hinting at with a "reverse-translation step" and without something like a label:
directive like @smeijer I can't think how composition of directives like this is possible without actual CSS-in-JS generation and then deep merging of objects (this is how Oceanwind worked).
Anyway, I am excited by all the possibilities listed here still. Just thought I'd share the context I have in this problem space!
const base = tw`bg-blue-500` const ex = tw`${base} bg-red-500`
i still don't fully understand why this is non-trivial.
i get that ${base}
is already the processed input (so may no longer be bg-blue-500
). but in this example wouldn't it just result in: bg-blue-500 bg-red-500
and somewhere we note that all bg
classes override each other? so we end up with bg-red-500
?
is the difficult part keeping track of what overrides what?
@lukejacksonn do you have an example maybe of where this wouldn't be so simple?
the JS representations make it very easy to compose, but it'd also be very nice if we could only deal with class names, making our output CSS almost equivalent to what tailwind itself would have.
Thank you all for your input. I try to address these here and will update the initial post.
Why not keep track of which directives have been used?
The problem is, sorry for not being more clear in the problem statement, that it is really difficult to know which directive does override another. Lets stick with bg-*
but there are others. The bg
prefix and its plugin handle several css properties where background-color
is only one of them.
background-color
: bg-current
, bg-gray-50
, ... (see https://tailwindcss.com/docs/background-color)background-attachment
: bg-local
, ... (see https://tailwindcss.com/docs/background-attachment)--tw-bg-opacity
: bg-opacity-10
, ... (see https://tailwindcss.com/docs/background-opacity)This ambiguity makes class based composition really difficult. That was the reason we introduced the override
variant.
tailwindcss and twin-macro both use style composition which merges the css properties which would solve this problem for most cases.
I still think that style composition is the best possible way right now to solve component composition while keeping utility classes for all other case.
When I should use
tw
vs. when I should usecompose
?
I totally agree that the addition of compose
makes the public API more difficult to reason about. Here are my reasons why i think that compose
is something different than tw
tw
parses the passed tokens, injects the respective css rules (several css classes, one for each token – just like tailwind) and returns a string with class names to use. After the call of tw
the user can be sure that the css styles are available in the stylesheet. Each token will
compose
would parse the passed tokens, merges the css rules and returns an inline directive to be used with tw
. No styles have been added to the stylesheet. Only after the result of compose
has been passed to tw
the styles (one css class) will be available.
I think that tw
and compose
solve two different use cases. tw
injects several classes and compose
holds and merges several directives into one class to allow composition.
ah i get you now, thanks for the great explanation 👍
not all bg-*
are actually setting the background, they may be setting some other variable or some narrower property. and the same will apply to most other classes.
so really this is an issue tailwind themselves also have (technically)?
what this means is we'd no longer be an implementation of tailwind, as we are now, but would also have our own feature set on top of that (composition). this library would diverge to be "tailwind and more".
thinking about it, i think personally i would argue that it remain "dumb" and just append the styles as it does now, maybe in the same order tailwind has them at least. so bg-blue bg-red
would result in the two rules being appended and conflicting, which would be your own fault as the consumer.
if that is my opinion then i suppose im in agreement with two functions (compose
and tw
) as it means tw
can remain "pure" and implement exactly what tailwind itself does.
otherwise, HTML written using tailwind classes might behave differently using this library than it would using tailwind itself.
Here's an idea. This is probably somewhat of a sin in TS land (and in general), but what if we attached properties to the resulting string from tw
that tells which css properties are in it?
const twMeta = Symbol()
type TwMeta = { cssProperties: {} }
interface String {
[twMeta]?: TwMeta
}
const className = tw`bg-blue-500`
className[twMeta] // { backgroundColor: "blue", "--tw-opacity": 1 }
Then, if the tw
function sees an interpolation, and one of the interpolated strings has metadata attached to it, instead of producing a normal tailwind classname, it merges the properties and produces a compose
d class.
First issue that comes to mind is the possibility of losing that metadata attachment in transport somehow, but I'm not sure if/how that could happen. I feel like that'd only happen if the user's trying to do something really clever.
I guess the goal is just to have some metadata associated with each generated class name string somewhere, so then the tw
function should know whether or not to do composition.
To approach from another angle, I would solve this at setup
phase through aliases:
import { setup } from "https://cdn.skypack.dev/twind/shim"
const btn = `
py-2 px-4
font-semibold
rounded-lg shadow-md
focus:(outline-none ring(2 indigo-400 opacity-75))
`
const btnIndigo = `${btn} bg-indigo(500 hover:700) text-white`
setup({
...
aliases: { btn, btnIndigo }
})
Then, in code you would refer to the aliases through btn
and btnIndigo
.
In practice, I've found that having composition alone isn't enough and you need a proper component model to have a complete abstraction but for simple ones something like here would be enough and I believe it would replicate Tailwind's @apply
nicely.
The caveat of my approach is that now you are forced to use setup
to define aliases but at the same time it gives you a single place where to find them.
To approach from another angle, I would solve this at
setup
phase through aliases:import { setup } from "https://cdn.skypack.dev/twind/shim" const btn = ` py-2 px-4 font-semibold rounded-lg shadow-md focus:(outline-none ring(2 indigo-400 opacity-75)) ` const btnIndigo = `${btn} bg-indigo(500 hover:700) text-white` setup({ ... aliases: { btn, btnIndigo } })
Then, in code you would refer to the aliases through
btn
andbtnIndigo
.
This is already possible using plugins:
setup({
plugins: {
btn: `
py-2 px-4
font-semibold
rounded-lg shadow-md
focus:(outline-none ring(2 indigo-400 opacity-75))
`,
btnIndigo : `btn bg-indigo(500 hover:700) text-white`,
}
})
While trying to link to the docs i noticed that this is missing. Sorry for that. I'll try to add this later.
If we do not want to introduce compose
and make composition a first-class tw
feature i have identified two areas we need to focus. First-class composition would mean the following:
const button = tw`bg-gray-500 rounded`
// => bg-gray-500 rounded
const redButton = tw`${redButton} bg-red-500`
// current implementation => bg-gray-500 rounded bg-red-500
// what we want => rounded bg-red-500
This should be the injected css:
/* No .bg-gray-500 injected */
.bg-red-500 {
--tw-bg-opacity: 1;
background-color: rgba(239, 68, 68, var(--tw-bg-opacity));
}
.rounded {
border-radius: 0.25rem;
}
As @JoviDeCroock has suggested this needs some way of reverse translation and maybe (optional) making style injection lazy like we already do for keyframes
.
1. Lazy Injection (optional)
Currently styles are injected into the sheet during the evaluation of the tw
call which would lead to injection of styles that may not be needed.
To work around that we could switch to deferred injection of styles: tw
returns an object with toString
and valueOf
which when called inject the styles. This is how twind/css
keyframes
is implemented.
2. Reverse Translation
This is the tough one. What i imagine could work would be something like this:
for each rule check if their css is contained within the css object; if that is the case include it in the output
Step 1 and 2 are possible. Step 3 may have some edge cases like what to do if the css is a partial match:
.bg-red-500 {
--tw-bg-opacity: 1;
background-color: rgba(239, 68, 68, var(--tw-bg-opacity));
}
.bg-opacity-5 {
--tw-bg-opacity: 0.05;
}
bg-opacity-5
partially overrides bg-red-500
. Both must be included in the output.
Another edge case may be if the css
helper is used. And i'm sure there a some i haven't identified yet.
I think first-class composition would be great but i'm unsure if it could be implemented in a reliable, performant way. compose
on the other hand adds an additional API that maybe confusing to use but could be easier to implement.
The ease of implementation on the part of compose
is a significant upside, but I feel like reducing complexity and handling more edge cases on the user's end is more important. Or in other words, making things work intuitively as one would expect them to.
I'll also point out that the requirement of generating vanilla tailwind classes is at odds with trying to nail down a good UX here. We're falling victim to the CSS cascade 😕
just to take a step back again, do you have an example of when someone would need this?
sorry to keep coming back to this. but lets say tailwind's own css defines this:
.bg-blue {
background: blue;
}
.bg-red {
background: red;
}
with tailwind, if you did class="bg-blue bg-red"
, you'd end up with red
of course. tailwind inevitably must have this problem right now, and must have their own order of rules.
this isn't really a "problem" because its your own fault for specifying two backgrounds.
so whats a real use case of when you'd purposely want to do this?
for example:
const base = tw`bg-red`;
tw`${base} bg-blue`;
we would right now produce bg-red bg-blue
which would be reliant on what order we chose for our overall stylesheet.
presumably the use case here is when you want to do this kind of composition and have the stylesheet ordered by your input rather than what we decided initially.
if thats the case, i'd still take the hit on API size to keep this extra functionality separate. again, tw
right now pretty much maps to the same thing tailwind itself would do, which is what users would expect. if you add this composition into tw
itself, you've branched off into having your own behaviour no longer aligned to tailwind.
if anything, i'd order rules the same as tailwind somehow (when using tw
) and have a separate composition API which appends custom classes after all the defaults/base.
edit:
i suppose its not so bad/misaligned as long as this new behaviour only happens to nested tw
results. so if you never compose that way, things work exactly as they do now
with tailwind, if you did class="bg-blue bg-red", you'd end up with red of course. tailwind inevitably must have this problem > right now, and must have their own order of rules.
this isn't really a "problem" because its your own fault for specifying two backgrounds.
so whats a real use case of when you'd purposely want to do this?
The use case to allow user of a component or component author to override some styles (the example is in react but it applies to other frameworks aswell):
const Button = ({ className, children}) => {
return <button className={tw`bg-gray-500 text-base ${className}`}>{children}</button>
}
const ButtonBlock = ({ className, children}) => {
return <Button className={`block ${className}`}>{children}</Button>
}
<Button>gray-500</Button>
<Button className="bg-red-500 text-lg">red-500 large</Button>
i suppose its not so bad/misaligned as long as this new behaviour only happens to nested
tw
results. so if you never compose that way, things work exactly as they do now
Yeah, this is pretty much where I'm at. I think the extra power and peace of mind is worth misalignment in some cases
tailwind inevitably must have this problem right now, and must have their own order of rules.
It does, and the best way I've seen to solve it is to not have class overrides, or in other words, only include the classes you want. So instead of [red, condition && blue]
, we'd say condition ? blue : red
so there's no chance of a conflict. But, working with twin macro and other CSS in JS libs, it was nice to not have to worry about that. We're in JS land, and if we have the power to circumvent the cascade in a user-friendly way, we should. But I also recognize priorities of this project may differ from my own, just throwing this out there.
ok yeah the case makes more sense now.
IMO then i would aim for that kind of api:
tw
by itself behaves as it does now, untouchedtw
has this new behaviourto aim for this:
tw`bg-red bg-blue`;
// css .bg-red {}, .bg-blue {} are appended
// result is bg-red bg-blue
const base = tw`bg-red`;
// css .bg-red {} is NOT appended as it already was on line 1
// result is bg-red
tw`${base} bg-blue`;
// css .generated-bg-blue{} is appended
// result is bg-red generated-bg-blue
where the last one is tough to detect as you need a map somewhere, or some cache, to know "this string is a nested tw result".
edit:
something logically like:
function tw(strings, ...expressions) {
if (expressions.some((expr) => exprIsTw(expr))) {
// here we somehow key/hash this call's args
// and generate a new css class with it, which we
// prefix all the tailwind classes in `strings` with
// which came after a `tw` expression.
// we then append those classes if they haven't
// been already (we'd know because its a hash/key in a map)
} else {
// do as normal
}
}
I have adjusted the proposal text to outline the problem, explain our requirements and show several possible solutions. Please continue to discuss so that we find a viable solution.
I think what @43081j suggests is great, iirc there is already a rules-to-className cache or what that only in oceanwind? If there is one we could probably take
function tw(strings, ...expressions) {
if (expr = expressions.filter((expr) => exprIsTw(expr))) {
// convert className to oldSetofRules and retrigger tw
} else {
// do as normal
}
}
The code provided and translate classNames back to their corresponding tw string, this allows for reuse and presedence checks.
Thanks for the update. Nested tw
feels like the best way to go for me.
How to ensure that generated-bg-blue has a higher precedence than bg-red?
Not sure if this is possible, or if there are potential issues with this, but maybe you could keep two DOM stylesheets, one for the regular TW rules, and one for the generated rules, so the generated rules would always be in the DOM after the normal rules, therefore having higher precedence. Another option would be to specify the generated rules twice, so .generated.generated {}
Is a part of this proposal to solve the issue shown here? https://codesandbox.io/s/flamboyant-water-pd4xl?file=/src/index.js
There's no nesting or anything, this is just hitting the cascade. Might be unrelated, but I wonder if there's a way to make this work with class name order. override
is a thing, but I figure we were trying to avoid needing that
https://codesandbox.io/s/flamboyant-water-pd4xl?file=/src/index.js
That is normal tailwindcss behavior. text-blue-500
is defined later in the stylesheet and wins. That would be solved only with solution 2. Reverse Translation.
Not sure if this is possible, or if there are potential issues with this, but maybe you could keep two DOM stylesheets, one for the regular TW rules, and one for the generated rules, so the generated rules would always be in the DOM after the normal rules, therefore having higher precedence. Another option would be to specify the generated rules twice, so
.generated.generated {}
We already have a presedence order and can control in which order css rules are inserted in the stylesheet. My problem is how do we solve this for arbitrary depths:
// Somewhere in the app
tw`bg-green`
tw`bg-blue`
tw`bg-red`
// => order in stylesheet: .green{} .blue{} .red{}
// Later in the app
const red = tw`bg-red`;
const blue = tw`${red} bg-blue`;
// => should be blue but is red because .red{} is later in the stylesheet
const green = tw`${blue} bg-green`;
// => should be green but is red because .red{} is later in the stylesheet
I'm trying to wrap my head around how solution 1. Nested tw could be implemented.
Some issues i see:
const base = tw`bg-red`;
tw`${base} bg-blue`;
tw`bg-green ${base} bg-blue`;
bg-red
would be injected into the stylesheet (because of line 1) although it is not needed.bg-green
is injected last into the stylesheetHow do we detect that a interpolation is somehow special?
tw`${base} bg-blue`;
vs
tw`${"bg-red"} bg-blue`;
I'm still in favor of putting this into a separate module because
tw
would now matter; until now it was the presedence in the stylesheetI have been thinking about it, and comparing it to how lit-element works in fact.
In lit-element, the way they know if an expression is another template is by having a TemplateResult
:
const base = css`xyz`; // TemplateResult instance
css`foo ${base} bar`; // detects that `base` is a TemplateResult and does any merging, etc
and in my proposed solution, having some exprIsTw
helper is working around this gap of knowing for sure what is a tw
result and what isn't.
so it probably does make sense to have a compose
if you want to be safe:
const base = tw`foo`; // this is a string now
tw`x ${base} y`; // would be "x foo y" because it is string interpolation
tw`x ${compose(base)} y`; // would be whatever our generated classes are, because we're explicitly telling it to
it would be super nice if it "just worked" via tw
only, but you're right it adds weight to it and complicates the implementation.
if you can keep the implementation in the base the same roughly, and all of this compose
stuff be an extra separate layer, that'd be good i think and removes the need for an exprIsTw
.
i think a primary goal should be to keep tw
aligned to tailwind itself, which means not doing any custom shuffling around of rules etc
If tailwind alignment is important, then compose
is a good solution. But personally, I'd be willing to throw tailwind alignment out the window, if it meant being able to rely on class name order for the correct styles. Ergo, generating different hashed class names and styles for red blue
vs. blue red
, similar to twin.macro. That seems like a different issue, though
Ergo, generating different hashed class names and styles for
red blue
vs.blue red
, similar to twin.macro.
That would be possible with compose
.
const base = tw`foo`; // this is a string now tw`x ${base} y`; // would be "x foo y" because it is string interpolation tw`x ${compose(base)} y`; // would be whatever our generated classes are, because we're explicitly telling it to
I really like the inline compose example.
it would be super nice if it "just worked" via tw only, but you're right it adds weight to it and complicates the implementation.
Me too.
if you can keep the implementation in the base the same roughly, and all of this compose stuff be an extra separate layer, that'd be good i think and removes the need for an exprIsTw. i think a primary goal should be to keep tw aligned to tailwind itself, which means not doing any custom shuffling around of rules etc
I agree.
Ergo, generating different hashed class names and styles for
red blue
vs.blue red
, similar to twin.macro.That would be possible with
compose
.
Right, I mean without an entire separate API to accomplish it, having tw
itself always return an order-sensitive hash. It feels more natural and ergonomic this way. Maybe this can be done through a plugin in tw?
It feels more natural and ergonomic this way.
😄 For you. I think (hope) most css and tailwind users are aware that the order of class names in the class attribute does not matter. We would change the expected behavior.
It should be possible with the new api to do exactly what you want:
// order-sensitive hash; x = the new api
const osh = (...args) => tw(x(...args))
osh`bg-red bg-blue`
// => tw-red-blue
osh`bg-blue bg-red`
// => tw-blue-red
I think we now agree that the problem is component based composition and tw
should keep the expected behavior tailwind behavior.
tailwind has a component concept using @apply which basically merges the css rules of several tailwind classes into one class. twin.macro does the same.
I would say we change our perspective to view this as a component definition problem.
As a component author i want to re-use tailwind directive styles for defining my component and allow users of the component to override styles using tailwind directive. Additionally i want to be able to extend a component and override or add some styles using tailwind rules.
// Using x as an placeholder for the name
const btn = x`inline-block bg-gray-500 text-base`
// => generates on css class with all declarations of the above rules when used
const btnBlick = x`${btn} block`
// => generates on css class with all declarations of btn & block
// Never used => never injected
<button class={tw`${btn}`}>gray-500</button>
// => tw-btn
<button class={tw`${btn} bg-red-500 text-lg`}>red-500 large</button>
// => tw-btn bg-red-500 text-lg
Lets take a look at the available APIs in twind and their transformations:
tw
: one tailwind rule => one class name – with side effect of inserting the css into the stylesheetcss
: css rules => one class name (via tw) – lazy evaluated (injected by tw on first use)When i look at this i see a missing piece:
x
: several tailwind rules => one class name (via tw) – lazy evaluated (injected by tw on first use)That API needs to
tw
inject stylesWhy lazy? Additionally to prevent unnecessary style injection it may interfere with importing a component library that uses this API before invoking
setup
.
Could we agree that such an API would be useful?
Naming this functionality is difficult here are some ideas:
tw.apply
=> to mirror tailwindcss @applycss.of
=> as it create one big css object basicallytranslate
=> as it translate tailwind rules to an css objectcompose
=> as it merges tailwind rules togetherI think (hope) most css and tailwind users are aware that the order of class names in the class attribute does not matter.
This Very Scientific Poll™️ convinced me otherwise! And I learned from this very tweet that class name order in HTML didn't matter 🙃 https://twitter.com/mxstbr/status/1038073603311448064
All in all, I feel like this is going to be a bit of a surprising footgun for people coming from twin.macro, or from CSS-in-JS in general. I'd even argue that not having to worry about the cascade is sort of a CIJ staple at this point.
It should be possible with the new api to do exactly what you want:
This is good. Definitely add this to the docs as a recommended way of avoiding the cascade
tw.apply
=> to mirror tailwindcss @apply
I like this, it follows the same logic as vanilla TW: creating a new utility class from existing ones.
Here are some examples using tw.apply
to get a feeling for the API:
css
– pendingI'm dealing with a family emergency right now so I'll be offline for the next week or so. I'll have to catch up on all of this when I get back, but great ideas and feedback from what I have seen so far!
I have update the propsal text to reflect the idea from https://github.com/tw-in-js/twind/issues/73#issuecomment-758784186.
@gojutin I hope your family will be fine and you find the time to be with them in these trying times.
It would be great if you could give me a 👍 if you are happy with the proposed solution. Please leave a comment if you are not.
/cc @tw-in-js/contributors
Hey guys,
I'm sorry bother you. I started using the tw.apply
and it didn't felt right. One coworker run straight into: tw.apply(customTW, args)
– which does not work. I thought about it and it seemed to be legit case. That's why I changed tw.apply
to just apply:
// import { apply, theme} from 'twind'
// twind/css re-exports apply and theme for convenience
import { css, apply, theme } from 'twind/css'
const prose = css`
/* Extract CSS from these Tailwind rules */
${apply`text-gray-700 dark:text-gray-300`}
/* Combine with CSS declarations */
p {
color: rebeccapurple;
background-color: ${theme('colors.gray.500')};
&:hover {
${apply`text-purple-700`}
}
}
/* Define global styles */
:global {
a {
/* Access theme values */
color: ${theme('colors.blue.500')}
}
}
`
I already pushed it to main and released v0.13.0 with the new API.
I hope that is ok with you all... I try to do better next time.
This does make sense to me, for what it's worth. Feels like a good set of tools, very reminiscent of native Tailwind functions + Emotion css syntax. I dig it. I'll be playing for sure!
I'll do some experimenting with the new API this week and report back, but it seems very intuitive from your examples.
While working on
twind/styled
(PR #7) i realized that twind does not have a good component composition model. With component composition we mean re-using styles for several components while allowing to override certain styles like changing the background color.Problem Statement
The problem we try to solve is component based composition while
tw
should keep the expected tailwind behavior.One way to do composition is utility combinations to recreate the same component in many different places (see Extracting Components). I would call this class composition as it applies or groups several class names for a component.
Details with an example and its problems
```jsx const Button = ({ className, children}) => { return } const ButtonBlock = ({ className, children}) => { return } ``` The example above does not reliably work because the injected css classes have all the same specificity and therefore the order they appear in the stylesheet determine which styles are applied. It is really difficult to know which directive does override another. Lets stick with `bg-*` but there are others. The `bg` prefix and its plugin handle several css properties where `background-color` is only one of them. - `background-color`: `bg-current`, `bg-gray-50`, ... (see https://tailwindcss.com/docs/background-color) - `background-attachment`: `bg-local`, ... (see https://tailwindcss.com/docs/background-attachment) - `--tw-bg-opacity`: `bg-opacity-10`, ... (see https://tailwindcss.com/docs/background-opacity) - and a lot more - not to forget about user plugins and inline directives This ambiguity makes class based composition really difficult. That was the reason we introduced the `override` variant. Consider the following `twind/styled` (PR #7) example: ```js const Button = twind.button` text(base blue-600) rounded-sm border(& solid 2 blue-600) m-4 py-1 px-4 ` // Create a child component overriding some colors const PurpleButton = twind(Button)` override:(text-purple-600 border-purple-600) ` ``` As you see it is difficult to override certain utility classes on usage or when creating a child component. For this to work twind introduced the `override` variant which increases the specificity of the classes it is applied to. But what do you do for a grandchild component or if you want to override the `PurpleButton` styles? `override:override:...`? There must be a better way to solve this problem.tailwind has a component concept using @apply which basically merges the css rules of several tailwind classes into one class. twin.macro does the same.
That is something I would call style composition and is currently not available in twind.
Details of tailwind @apply
Tailwindcss provides [@apply to extract component classes](https://tailwindcss.com/docs/extracting-components#extracting-component-classes-with-apply) which merges the underlying styles of the utility classes into a single css class. That is something i would call _style composition_ and is currently not available in twind. ```css .btn-indigo { @apply py-2 px-4 bg-indigo-500 text-white font-semibold rounded-lg shadow-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-opacity-75; } ``` [twind.macro](https://github.com/ben-rogerson/twin.macro) does the same during build time to generate css-in-js objects which are evaluated with a runtime like emotion or styled-component: ```js const hoverStyles = css` &:hover { border-color: black; ${tw`text-black`} } ` const Input = ({ hasHover }) => ( ) ``` > The `tw` function from `twin.macro` acts like the `@apply` helper from tailwindcss.Lets summarize both composition approaches:
Proposed Solution
Recap of available APIs in twind and their transformations:
tw
: one tailwind rule => one class name – with side effect of inserting the css into the stylesheetcss
: css rules => one class name (via tw) – lazy evaluated (injected by tw on first use)When i look at this i see a missing piece:
tw.apply
: several tailwind rules => one class name (via tw) – lazy evaluated (injected by tw on first use)All considers names
- `tw.apply` => to mirror tailwindcss @apply - `css.of` => as it create one big css object basically - `translate` => as it translate tailwind rules to an css object - `compose` => as it merges tailwind rules togetherThat API needs to
generate one style object eg one css class combining all tailwind rules by deep merging rules in order of declaration
allow utility classes applied on the same element override its styles; eg styles are injected after base (preflight) and before utility classes
can be used with
tw
=>tw(tw.apply(...))
; eg implement as an inline directiveallow to inject the styles and access the class name without calling
tw
=>result.toString()
andresult.valueOf()
support template literal, strings, arrays, objects and other inline directives (incl
css
) as parametersRule Precedence Calculation
To have a predictable styling the styles must be ordered. This order is represented by a precedence number. The lower values are inserted before higher values. Meaning higher precedence styles overwrite lower precedence styles. Each rule has some traits that are put into a bit set which form the precedence: | bits | trait | | ---- | ------------------------------------------------- | | 1 | dark mode | | 2 | layer: base = 0, components = 1, utilities = 2 , css = 3 | | 1 | screens: is this a responsive variation of a rule | | 5 | responsive based on min-width | | 4 | at-rules | | 17 | pseudo and group variants | | 4 | number of declarations (descending) | | 4 | greatest precedence of properties | **Dark Mode: 1 bit** Flag for dark mode rules. **Layer: 3 bits** - base = 0: The preflight styles and any base styles registered by plugins. - components = 1: Component classes and any component classes registered by plugins. - utilities = 2: Utility classes and any utility classes registered by plugins. - css = 3: Inline plugins **Screens: 1 bit** Flag for screen variants. They may not always have a `min-width` to be detected by _Responsive_ below. **Responsive: 5 bits** Based on extracted `min-width` value: - 576px -> 3 - 1536px -> 9 - 36rem -> 3 - 96rem -> 9 **At-Rules: 4 bits** Based on the count of special chars (`-:,`) within the at-rule. **Pseudo and group variants: 17 bits** Ensures predictable order of pseudo classes. - https://bitsofco.de/when-do-the-hover-focus-and-active-pseudo-classes-apply/#orderofstyleshoverthenfocusthenactive - https://developer.mozilla.org/docs/Web/CSS/:active#Active_links - https://github.com/tailwindlabs/tailwindcss/blob/master/stubs/defaultConfig.stub.js#L718 **Number of declarations (descending): 4 bits** Allows single declaration styles to overwrite styles from multi declaration styles. **Greatest precedence of properties: 4 bits** Ensure shorthand properties are inserted before longhand properties; eg longhand override shorthandbe lazy evaluated because it may never be used
Why lazy?
For one to prevent unnecessary style injection and to prevent problems when importing a component library that uses this API before invoking `setup`.Here are some examples using
tw.apply
to get a feeling for the API:Basic usage
Please note that the utility classes are always defined **after** the component styles which allows them to overrides certain styles. ```js import { tw } from 'twind' const btn = tw.apply` py-2 px-4 font-semibold rounded-lg shadow-md focus:(outline-none ring(2 indigo-400 opacity-75)) ` tw`${btn} font-bold` // => .tw-btn .font-bold // CSS: // .tw-XXXX { padding-top: 0.5rem; padding-bottom: 0.5rem; padding-left: 1rem; padding-right: 1rem; font-weight: 600; ...} // .font-bold { font-weight: 700; } const btnLarge = tw.apply`${btn} py-4 px-8` // Result: () => ({ paddingTop: '1rem', paddingBottom: '1rem', paddingLeft: '2rem', paddingRight: '2rem', fontWeight: '600', ... }) tw`${btnLarge} rounded-md` // => .tw-btn-large .rounded-md // CSS: // .tw-btn-large { padding-top: 1rem; padding-bottom: 1rem; padding-left: 2rem; padding-right: 2rem; font-weight: 600; ... } // .rounded-md { ... } ```twin.macro and styled-component compatibility eg generate one class
The would be possible as the returned function has `toString` and `valueOf` methods which inject the styles and return the class name: ```jsx // => tw-red-blue document.body.className = tw.apply`bg-blue bg-red` // => tw-blue-red ``` Or use this helper: ```jsx // There is a better name out there somewhere const twind = (...args) => tw(tw.apply(...args)) // => tw-red-blue document.body.className = twind`bg-blue bg-red` // => tw-blue-red ````css` can be used within `tw.apply`
```js const btn = tw.apply` py-2 px-4 ${css({ borderColor: 'black', })} ` ```Using within
`tw.apply` can be used with `css` ( (_pending variable arguments, array support_): ```js const prose = css( tw.apply`text-gray-700 dark:text-gray-300`, { p: tw.apply`my-5`, h1: tw.apply`text-black dark:text-white`, }, { h1: { fontWeight: '800', fontSize: '2.25em', marginTop: '0', marginBottom: '0.8888889em', lineHeight: '1.1111111', } } ) ``` Using template literal syntax (_pending, but i'm working on it_): ```js const prose = css` ${tw.apply`text-gray-700 dark:text-gray-300`) p { ${tw.apply('my-5')} } h1 { ${tw.apply`text-black dark:text-white`} font-weight: 800; font-size: 2.25em; margin-top: 0; margin-bottom: 0.8888889em; line-height: 1.1111111; } ` ```css
– pending`twind/styled` would then be a small react wrapper around `tw.apply`
```js const Button = twind.button` text(base blue-600) rounded-sm border(& solid 2 blue-600) m-4 py-1 px-4 ` const PurpleButton = twind(Button)` text-purple-600 border-purple-600 ` ```Using tailwind directives with `animation` from `twind/css`
```js const motion = animation('.6s ease-in-out infinite', { '0%': tw.apply`scale-100`, '50%': tw.apply`scale-125 rotate-45`, '100%': tw.apply`scale-100 rotate-0`, }) ```A react button component
```jsx import { tw } from 'twind' const variantMap = { success: "green", primary: "blue", warning: "yellow", info: "gray", danger: "red" } const sizeMap = { sm: tw.apply`text-xs py(2 md:1) px-2`, md: tw.apply`text-sm py(3 md:2) px-2`, lg: tw.apply`text-lg py-2 px-4`, xl: tw.apply`text-xl py-3 px-6` } const baseStyles = tw.apply` w(full md:auto) text(sm white uppercase) px-4 border-none transition-colors duration-300 ` function Button({ size = 'md', variant = "primary", round = false, disabled = false, className, children }) { // Collect all styles into one class const instanceStyles = tw.apply` ${baseStyles} bg-${variantMap[variant]}(600 700(hover:& focus:&))) ${sizeMap[size]} rounded-${round ? "full" : "lg"} ${disabled && "bg-gray-400 text-gray-100 cursor-not-allowed"} ` // Allow passed classNames to override instance styles return } render() ```Discared Proposed Solutions
### 1. Nested `tw` (https://github.com/tw-in-js/twind/issues/73#issuecomment-758094856) - `tw` by itself behaves as it does now, untouched - nested `tw` has a new behavior ```js tw`bg-red bg-blue`; // css .bg-red {}, .bg-blue {} are appended // result is bg-red bg-blue const base = tw`bg-red`; // css .bg-red {} is NOT appended as it already was on line 1 // result is bg-red tw`${base} bg-blue`; // css .tw-generated-bg-blue{} is appended // result is bg-red tw-generated-bg-blue ``` Open question @43081j: How to ensure that `generated-bg-blue` has a higher precedence than `bg-red`? ### 2. Reverse Translation (https://github.com/tw-in-js/twind/issues/73#issuecomment-758062412) > Enhance `tw` to detect directives that override previous ones and omit those from the result class names string. ```js const btn = tw`py-2 px-4 font-semibold` // => py-2 px-4 font-semibold tw`${btn} py-4 px-8` // => font-semibold py-4 px-8 tw`py-4 ${btn} px-8` // => py-2 font-semibold px-8 ``` **Algorithm** 1. transform all rules to their css equivalent 2. merge all css into one object 3. for each rule check if their css is contained within the css object; if that is the case include it in the output Step 1 and 2 are possible. Step 3 may have some edge cases like what to do if the css is a partial match: ```css .bg-red-500 { --tw-bg-opacity: 1; background-color: rgba(239, 68, 68, var(--tw-bg-opacity)); } .bg-opacity-5 { --tw-bg-opacity: 0.05; } ``` `bg-opacity-5` partially overrides `bg-red-500`. Both must be included in the output. Another edge case may be if the `css` helper is used. And i'm sure there a some i haven't identified yet. ### 3. twind/compose > Introduce `compose` as a new function which would extract the styles of the provided directives and returns an inline directive with an css style object containing all deep merged rules which can be used with `tw`. The generated styles would have a lower precedence than the utility classes which would allow to use tailwind directives to override styles. The following examples use template literals but well known `tw` arguments like strings, arrays, objects, and inline directives, would be supported. ```js import { compose } from 'twind/compose' const btn = compose` py-2 px-4 font-semibold rounded-lg shadow-md focus:(outline-none ring(2 indigo-400 opacity-75)) ` // Result: () => ({ paddingTop: '0.5rem', paddingBottom: '0.5rem', paddingLeft: '1rem', paddingRight: '1rem', fontWeight: '600', ... }) // CSS: // .tw-XXXX { padding-top: 0.5rem; padding-bottom: 0.5rem; padding-left: 1rem; padding-right: 1rem; font-weight: 600; ...} const btnLarge = compose`${btn} py-4 px-8` // Result: () => ({ paddingTop: '1rem', paddingBottom: '1rem', paddingLeft: '2rem', paddingRight: '2rem', fontWeight: '600', ... }) // CSS: // .tw-YYYY { padding-top: 1rem; padding-bottom: 1rem; padding-left: 2rem; padding-right: 2rem; font-weight: 600; ... } tw`${btnLarge} rounded-md` // => .tw-btn .tw-btn-large .rounded-md ``` `css` can be used within `compose`: ```js const btn = compose` py-2 px-4 ${css({ borderColor: 'black', })} ` ```Using within
`compose` can be used with `css` ( (_pending variable arguments, array support_): ```js const prose = css( compose`text-gray-700 dark:text-gray-300`, { p: compose`my-5`, h1: compose`text-black dark:text-white`, }, { h1: { fontWeight: '800', fontSize: '2.25em', marginTop: '0', marginBottom: '0.8888889em', lineHeight: '1.1111111', } } ) ``` Using template literal syntax (_pending, but i'm working on it_): ```js const prose = css` ${compose`text-gray-700 dark:text-gray-300`) p { ${compose('my-5')} } h1 { ${compose`text-black dark:text-white`} font-weight: 800; font-size: 2.25em; margin-top: 0; margin-bottom: 0.8888889em; line-height: 1.1111111; } ` ```css
– pending (Click to expand)Show more examples (click to expand)
Using tailwind directives with `animation` from `twind/css`: ```js const motion = animation('.6s ease-in-out infinite', { '0%': compose`scale-100`, '50%': compose`scale-125 rotate-45`, '100%': compose`scale-100 rotate-0`, }) ``` Here is an example for an react button component: ```jsx import { tw } from 'twind' import { compose } from 'twind/compose' const variantMap = { success: "green", primary: "blue", warning: "yellow", info: "gray", danger: "red" } const sizeMap = { sm: compose`text-xs py(2 md:1) px-2`, md: compose`text-sm py(3 md:2) px-2`, lg: compose`text-lg py-2 px-4`, xl: compose`text-xl py-3 px-6` } const baseStyles = compose` w(full md:auto) text(sm white uppercase) px-4 border-none transition-colors duration-300 ` function Button({ size = 'md', variant = "primary", round = false, disabled = false, className, children }) { const instanceStyles = compose` ${baseStyles} bg-${variantMap[variant]}(600 700(hover:& focus:&))) ${sizeMap[size]} rounded-${round ? "full" : "lg"} ${disabled && "bg-gray-400 text-gray-100 cursor-not-allowed"} ` // Allow passed classNames to override instance styles return } render() ```Show more examples (click to expand)
Using tailwind directives with `animation` from `twind/css`: ```js import { css, animation } from 'twind/css' const motion = animation('.6s ease-in-out infinite', { '0%': css('scale-100'), '50%': css('scale-125 rotate-45'), '100%': css('scale-100 rotate-0'), }) ``` Here is an example for an react button component: ```jsx import { tw } from 'twind' import { css } from 'twind/css' const variantMap = { success: "green", primary: "blue", warning: "yellow", info: "gray", danger: "red" } const sizeMap = { sm: css('text-xs py(2 md:1) px-2'), md: css('text-sm py(3 md:2) px-2'), lg: css('text-lg py-2 px-4'), xl: css('text-xl py-3 px-6') } const baseStyles = css(` w(full md:auto) text(sm white uppercase) px-4 border-none transition-colors duration-300 `) function Button({ size = 'md', variant = "primary", round = false, disabled = false, className, children }) { const instanceStyles = css(` ${baseStyles} bg-${variantMap[variant]}(600 700(hover:& focus:&))) ${sizeMap[size]} rounded-${round ? "full" : "lg"} ${disabled && "bg-gray-400 text-gray-100 cursor-not-allowed"} `) // Allow passed classNames to override instance styles return } render() ```Summary
I hope have summarized all sides of the discussion and everybody sees theirs points reflected in the proposed solution.
Thank you for reading this whole thing ❤️
/cc @tw-in-js/contributors