Component Proposal
See original GitHub issueThank you all for the feedback, questions, ideas and suggestions. I have edited the proposed solution o reflect the discussion. Proposed Draft 2020-13-01 04:50 GMT
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
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.
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
const Button = ({ className, children}) => {
return <button className={tw`inline-block 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>
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:
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 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.
.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 does the same during build time to generate css-in-js objects which are evaluated with a runtime like emotion or styled-component:
const hoverStyles = css`
&:hover {
border-color: black;
${tw`text-black`}
}
`
const Input = ({ hasHover }) => (
<input css={[tw`border`, hasHover && hoverStyles]} />
)
The
tw
function fromtwin.macro
acts like the@apply
helper from tailwindcss.
Lets summarize both composition approaches:
- class: apply several utility classes on an element
- style: extract utility css declarations and merge them into one css class
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 @applycss.of
=> as it create one big css object basicallytranslate
=> as it translate tailwind rules to an css objectcompose
=> as it merges tailwind rules together
const btn = tw.apply`inline-block bg-gray-500 text-base`
// => generates on css class with all declarations of the above rules when used
const btnBlick = tw.apply`${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
That 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 directive -
allow 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 shorthand
-
be 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.
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:
<button className={tw.apply`bg-red bg-blue`}>blue</button>
// => tw-red-blue
document.body.className = tw.apply`bg-blue bg-red`
// => tw-blue-red
Or use this helper:
// There is a better name out there somewhere
const twind = (...args) => tw(tw.apply(...args))
<button className={twind`bg-red bg-blue`}>blue</button>
// => tw-red-blue
document.body.className = twind`bg-blue bg-red`
// => tw-blue-red
`css` can be used within `tw.apply`
const btn = tw.apply`
py-2 px-4
${css({
borderColor: 'black',
})}
`
Using within css
– pending
tw.apply
can be used with css
( (pending variable arguments, array support):
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):
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;
}
`
`twind/styled` would then be a small react wrapper around `tw.apply`
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`
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
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 <button className={tw(instanceStyles, className)}>{children}</button>
}
render(<Button variant="info" className="text-lg rounded-md">Click me</Button>)
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
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.
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
- transform all rules to their css equivalent
- merge all css into one object
- 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.
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 withtw
. 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.
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
:
const btn = compose`
py-2 px-4
${css({
borderColor: 'black',
})}
`
Using within css
– pending (Click to expand)
compose
can be used with css
( (pending variable arguments, array support):
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):
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;
}
`
twind/styled
would then be a small react wrapper around the base compose
:
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
`
Show more examples (click to expand)
Using tailwind directives with animation
from twind/css
:
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:
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 <button className={tw(instanceStyles, className)}>{children}</button>
}
render(<Button variant="info" className="text-lg rounded-md">Click me</Button>)
4. Enhance twind/css
Extend
twind/css
to extract the styles of the provided directives and return an inline directive with an css style object containing all deep merged rules which can be used withtw
. The generated styles would have a lower precedence than the utility classes which would allow to use tailwind directives to override styles.
css currently accepts an css object. We could extend it to accept strings which are directives:
css
would now be a translator from tailwind rules to css object.
Please note that the template literal syntax may come with issues in editors and prettier as it may be mistaken for real css. If anyone has a solution please comment below.
const btn = css('py-2 px-4 font-semibold')
// => { padding-top: 0.5rem; padding-bottom: 0.5rem; padding-left: 1rem; padding-right: 1rem; font-weight: 600; }
tw`${btn} py-4 px-8`
// => tw-xxx py-4 px-8
const largeBtn = css`${btn} py-4 px-8`
// => { padding-top: 1rem; padding-bottom: 1rem; padding-left: 2rem; padding-right: 2rem; font-weight: 600; }
tw`${largeBtn} font-bold`
// => tw-yyyy font-bold
Show more examples (click to expand)
Using tailwind directives with animation
from twind/css
:
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:
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 <button className={tw(instanceStyles, className)}>{children}</button>
}
render(<Button variant="info" className="text-lg rounded-md">Click me</Button>)
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 ❤️
Issue Analytics
- State:
- Created 3 years ago
- Reactions:4
- Comments:47 (1 by maintainers)
Top GitHub Comments
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
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.
twin.macro and styled-component compatibility eg generate one class
The would be possible as the returned function has
toString
andvalueOf
methods which inject the styles and return the class name:Or use this helper:
`css` can be used within `tw.apply`
Using within
css
– pendingtw.apply
can be used withcss
( (pending variable arguments, array support):Using template literal syntax (pending, but i’m working on it):
`twind/styled` would then be a small react wrapper around `tw.apply`
Using tailwind directives with `animation` from `twind/css`
A react button component