Skip to content

Supporting twin.macro-like props? #290

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

Open
therealgilles opened this issue Mar 15, 2025 · 26 comments · May be fixed by #291
Open

Supporting twin.macro-like props? #290

therealgilles opened this issue Mar 15, 2025 · 26 comments · May be fixed by #291

Comments

@therealgilles
Copy link

Any chance to support twin.macro-like component props like:

       <div css={tw`mb-8 flex flex-col items-center self-end`}>...</div>
       <div css={[tw`flex gap-4`, isSomething ? tw`basis-8/12` : tw`w-full`]}>
       <div tw="flex flex-col gap-10">...</div>

I'm trying out the css/atoms combination but it's so less efficient code-wise with prettier putting everything on multiple lines:

  // BEFORE
  <div tw="relative flex min-h-20 flex-col">...</div>

  // AFTER
  <div
    css={css`
      ${atoms('relative flex min-h-20 flex-col')}
    `}
  >...</div>
@jantimon
Copy link
Collaborator

jantimon commented Mar 15, 2025

This look interesting...

We might support css={atoms`...`} as a first step as it should be relatively straightforward to implement

I'll discuss this with @Mad-Kat who built the CSS JSX prop into next-yak

@therealgilles
Copy link
Author

That would be most excellent.

@Mad-Kat
Copy link
Contributor

Mad-Kat commented Mar 15, 2025

Yes I think using css={atoms...} as a shorthand for atoms would be possible. @therealgilles would the static variant for it be sufficient? And when you need dynamic styles you can always fall back to the other variant. What do you think?

@therealgilles
Copy link
Author

@Mad-Kat What would be the equivalent of the following in next-yak? The documentation is a little ambiguous about what type dynamic styles are available with the css prop.

   <div css={[tw`flex gap-4`, isSomething ? tw`basis-8/12` : tw`w-full`]}>

@Mad-Kat
Copy link
Contributor

Mad-Kat commented Mar 16, 2025

At the moment the dynamic styling of the CSS prop hasn't a very good support. One syntax that works is:

<div
      css={css`
        ${atoms('flex gap-4')}
        ${() =>
          isSomething ?
          css`${atoms('basis-8/12')}`
          : css`${atoms('w-full')}`}
      `}
    />

Altough it's not very ergonomic, it should work. The other approach would be to use styled.div and create a separate component for these styles.

@jantimon do you have any idea on how to make the ergonomics for these cases (with atoms) better?

@therealgilles
Copy link
Author

Would something like this work? or is that not supported?

    <div
      css={css`
        ${atoms('flex gap-4')}
        ${atoms(isSomething ? 'basis-8/12' : 'w-full')}
      `}
    />

@Mad-Kat
Copy link
Contributor

Mad-Kat commented Mar 16, 2025

You're right. This is even easier and should work perfectly fine as is. I will try to see if I can implement the shorthand syntax.

Would that suffice for you?

You can also use it like this at the moment:

    <div
      css={css`${atoms('flex gap-4', isSomething ? 'basis-8/12' : 'w-full')}}
    />

So once the shorthand is possible you could also write it like this:

    <div
      css={atoms('flex gap-4', isSomething ? 'basis-8/12' : 'w-full')}
    />

@Mad-Kat Mad-Kat linked a pull request Mar 16, 2025 that will close this issue
@therealgilles
Copy link
Author

That looks great and should be sufficient. I'll see if other twin.macro use cases appear problematic.

From a code layout viewpoint, be able to use an array is sometimes advantageous, so if you don't find it too odd, making atoms support arrays would be great (ignoring falsy values), like this:

    <div css={atoms(['flex gap-4', isSomething ? 'basis-8/12' : 'w-full', isSomethingElse && 'absolute'])} />

which brings a question: would this work? i.e. does atoms already ignore falsy values?

    <div css={atoms('flex gap-4', isSomething && 'w-full')} />

Part of the reason I like twin.macro is because of how well it integrates with the jsx code, i.e. most of the time, the css/tw prop will be on the same line as the HTML tag or React component, not taking additional vertical space, making the code easy to read.

I want to thank you for being open to this discussion. A Rust-based path forward for twin.macro (and eventually with Turbopack) has the potential for making a lot of twin.macro users happy. The babel plugin had unfortunately reached a hard-to-get-around impasse with everything moving quickly towards Rust and Turbopack, and the added complexity of RSC.

@therealgilles
Copy link
Author

therealgilles commented Mar 16, 2025

I am noticing that the css prop has any as a type. Is there a way to add proper typing?

Image

This brings one more scenario to mind. One thing I've done with twin.macro is pass TwStyle | TwStyle[] (this is the type twin.macro uses for its css/tw props) from one component to another, like this:

   const MyComponent = ({ imageCssProps }: { imageCssProps: TwStyle }) => (
     <Image css={imageCssProps} />
   )
   const MyParentComponent = () => (
     <MyComponent imageCssProps={tw`min-h-[600px] object-[center_left]`} />
   )

Would either of the three options below work?

  1. Pass a string instead:
   const MyComponent = ({ imageCssProps }: { imageCssProps: SomeStringStyle }) => (
     <Image css={css`${atoms(imageCssProps)}`} />
   )
   const MyParentComponent = () => (
     <MyComponent imageCssProps="min-h-[600px] object-[center_left]" />
   )
  1. Pass the atoms() returned value:
   const MyComponent = ({ imageCssProps }: { imageCssProps: ReturnType<typeof atoms> }) => (
     <Image css={css`${imageCssProps}`} />
   )
   const MyParentComponent = () => (
    <MyComponent imageCssProps={atoms('min-h-[600px] object-[center_left]')} />
   )
  1. Pass the css string literal returned value:
   const MyComponent = ({ imageCssProps }: { imageCssProps: SomeCssStyle }) => (
     <Image css={imageCssProps} />
   )
   const MyParentComponent = () => (
    <MyComponent imageCssProps={css`${atoms('min-h-[600px] object-[center_left]')}`} />
   )

@Mad-Kat
Copy link
Contributor

Mad-Kat commented Mar 17, 2025

We're always happy to help and shape the future of CSS-in-JS together. It's always valuable to have others try and report issues that we never experienced, because our focus was on our problem we had at the time.

The first goal was always to replace styled-components with a build time alternative, but it looks like the CSS prop and now also the use-case of twin.macro could be supported in a acceptable way. So thank you for opening the issue :)

Regarding your questions:

i.e. does atoms already ignore falsy values?

At the moment it only works with strings, but adding the possibility to ignore a falsy value should be really easy.

I am noticing that the css prop has any as a type. Is there a way to add proper typing?

I've tried to write it down here. Maybe I should add it to the installation. Because the CSS prop should be on all native elements, we need to "wrap & override" the JSX specification of React.
If you either place /** @jsxImportSource next-yak */ in your file or "compilerOptions": { "jsxImportSource": "next-yak" } in your tsconfig.json it should work. And if it doesn't, please let us know.

Would either of the three options below work?

I tried to support these kind of operations with the CSS prop, but I don't know if it's the best way or if there are more intuitive ways. I would appreciate your input there to shape the API.
At the moment the type of the css prop is basically a function that returns some styling:

type ComponentStyles<TProps> = (props: TProps) => {
    className: string;
    style?: {
        [key: string]: string;
    };
};

This is only to be able to pass the css and atoms functions. During transpilation both functions will be transformed and called, so that they directly return an object with className and (if applicable for the css function) style property.
I've created a similar type to TwStyle called CSSProp:

type CSSProp = {
    /** This prop is transformed during compilation and doesn't exist at runtime. */
    css?: ComponentStyles<Record<keyof any, never>>;
    /** The css prop will return the name of the class and automatically merged with an existing class */
    className?: string;
    /** The css prop will return the style object and automatically merged with an existing style */
    style?: {
        [key: string]: string;
    };
};

That one could be use to accept a css property and after transpilation pass the className and style further along:

// definition
const Test = ({ className, style }: {} & CSSProp) => {
  return (
    <div className={className} style={style}>
      Test
    </div>
  );
};
// usage
<Test css={atoms("salmon")} />

Or you could also use it without a css prop:

// use the return type of atoms function or manually annotate it with { className: string}
const Test = ({ myAtoms }: { myAtoms: { className: string } }) => {
  return <div className={myAtoms.className}>Test</div>;
};
// usage. Note the empty parentheses to call the resulting function and create the className
<Test myAtoms={atoms("salmon")()} />

I know the whole part is not very fleshed out, but there should at least be a way for you to achieve the migration.

I will sync with @jantimon to see if we come up with another idea to make this API better.

@Mad-Kat
Copy link
Contributor

Mad-Kat commented Mar 18, 2025

@therealgilles Can you share some more examples how you use the TwStyle type in your custom components? This would help me to think of a better suited API and less migration pains for you.

Additionally I would love to know if you have more complicated cases to migrate and maybe you could share some of them? Just to think it through how our API should evolve and how easy or complicated it might be to migrate for you.

@therealgilles
Copy link
Author

therealgilles commented Mar 19, 2025

I had already set /** @jsxImportSource next-yak */ but I'm still getting any as type for the CSS prop somehow.

Ah but I probably have a conflict with this twin.d.ts settings:

import { css as cssImport } from '@emotion/react'
import { CSSInterpolation } from '@emotion/serialize'
import styledImport from '@emotion/styled'

import 'twin.macro'

declare module 'twin.macro' {
  // The styled and css imports
  const styled: typeof styledImport
  const css: typeof cssImport
}

declare module 'react' {
  // The tw and css props
  interface DOMAttributes<T> {
    tw?: string
    css?: CSSInterpolation
  }
  interface HTMLAttributes<T> extends DOMAttributes<T> {
    css?: Interpolation<Theme>
    tw?: string
  }
  interface SVGProps<T> extends SVGProps<SVGSVGElement> {
    css?: Interpolation<Theme>
    tw?: string
  }
}

and I see I forgot to import Interpolation and Theme, which explains the any type.

@Mad-Kat
Copy link
Contributor

Mad-Kat commented Mar 19, 2025

Yes exactly. twin.macro does exactly the same thing as we do with jsxImportSource and I think one cancels the other out.

@therealgilles
Copy link
Author

It does not seem possible to support both in the same file. I was hoping I could work around it by overriding the interfaces but I think it's a type, so it cannot be overridden. This makes the transition a bit trickier.

Thinking about this more, maybe it wasn't such a good idea trying to pass processed atoms from component to component. Passing the original strings (or an array of strings) is probably best and it will make the transition easier too.

I found an issue with atoms(). It does not handle Tailwind class priorities. For instance, with twin.macro, using this:

  <div css={[tw`bg-black`, tw`bg-[#0f0015]/40`]} />

would result in the latter having priority. But it appears it's not the case with atoms().

@therealgilles
Copy link
Author

Hmm am I not understanding how the css prop is supposed to work with atoms(). I don't see a class name being added, I only see the tailwind tags added to class. That's not going to work, nor is it what I need. What am I missing?

@Mad-Kat
Copy link
Contributor

Mad-Kat commented Mar 20, 2025

Thank you for your answer. Supporting both in the same file will be very tricky, as both try to transpile some portion of the code. Would the way forward for you be to replace emotion & twin.macro with next-yak or just having it alongside of them?

Yes keeping them string and only process at the end would be easier for us too.

I might have found a solution for my issue with the css prop that is really inspired from emotion as their typings are really good. I will try to bake that into a PR.

Regarding the class priorities. We don't do anything fancy with the provided strings. We just concat them and twin.macro seems to override the first with the latter. The provided example also doesn't work in vanilla tailwind. I don't think we should support specific logic for tailwind, as there might be other users that don't want to merge bg- or similar class names. But maybe we could add another utility function specifically for tailwind classes that handle the merging better.

Hmm am I not understanding how the css prop is supposed to work with atoms(). I don't see a class name being added, I only see the tailwind tags added to class. That's not going to work, nor is it what I need. What am I missing?

Can you share more information? I would be happy to dive a bit in to see what goes wrong.

@therealgilles
Copy link
Author

therealgilles commented Mar 20, 2025

I don't think we should support specific logic for tailwind, as there might be other users that don't want to merge bg- or similar class names.

I'm pretty sure everyone would want this. Let me try to explain. Here is what twin.macro does. This:

    <div css={[tw`absolute top-0 left-0 h-full w-full bg-black`, tw`bg-[#0f0015]/40`]} />

gets transformed into this:

    <div data-tw="absolute top-0 left-0 h-full w-full bg-black | bg-[#0f0015]/40" class="tw-oqf9q5"></div>

The data-tw is for debugging to see the original tailwind tags. The resulting class, here tw-oqf9q5 looks like this:

  .tw-oqf9q5 {
    position: absolute;
    left: 0px;
    top: 0px;
    height: 100%;
    width: 100%;
    --tw-bg-opacity: 1;
    background-color: rgb(0 0 0 / var(--tw-bg-opacity, 1));
    background-color: rgb(15 0 21 / 0.4);
  }

It's easy to see that the latter background color tag will take priority over the former one because it's placed after. BUT this only works because they are grouped into the same class.

Does this make sense? This is the only way that Tailwind makes sense with CSS-in-JS. At least in my opinion.

Maybe I can try tailwind-merge but that's really not the same. Ultimately what I want is a single class. I have no interest in Tailwind classes being included one by one.

@therealgilles
Copy link
Author

Is it not possible to run Tailwind to transform the tags into css or styles object and then process that the same way next-yak processes non-atom styles?

@jantimon
Copy link
Collaborator

jantimon commented Mar 21, 2025

Thanks @therealgilles for sharing all these insights

Here is my personal opinion

1. A dedicated tw helper with tailwind-merge

I believe that creating a specialized version of atoms that uses tailwind-merge under the hood would offer the best developer experience:

<div css={tw`bg-black`} />

This would enable clean component composition patterns:

const BaseButton = ({props}) => <button css={tw`bg-black text-xl`} {...props} />
const RedButton = ({props}) => <BaseButton css={tw`bg-red`} {...props} />

Behind the scenes, tailwind-merge would intelligently handle class conflicts, resulting in clean HTML like:

<button class="bg-red text-xl">a red button</button>

This functionality would fit best in a dedicated package (e.g., next-yak-tailwind) to:

  • Keep next-yak core focused
  • Allow independent versioning
  • Provide proper Tailwind typings
  • Add specialized Tailwind-specific functionality

Currently the API does not allow writing such a helper but I believe it is possible to add it to next-yak.
What do you think @Mad-Kat?

2. Compiling Tailwind to CSS

Is it not possible to run Tailwind to transform the tags into css or styles object and then process that the same way next-yak processes non-atom styles?

Yes that would be possible - with a next-yak-tailwind package we could offer a ts file with all existing utilities.
Providing dynamic utilities might be tricky though.

import { apply } from "next-yak-tailwind";

const Button = styled.button`
  ${apply["bg-red"]};
  ${apply["text-xl"]};
`

However in my opinion we should not provide such an API as the official tailwind documentation says:

we don't recommend using CSS modules and Tailwind together

And Adam Wathan the creator of Tailwind CSS said:

Tweet https://x.com/adamwathan/status/1559250403547652097

Providing an API which is against the goal of Tailwind feels wrong to me.

What do you think?

@therealgilles
Copy link
Author

Thank you for offering these ideas.

About combining Tailwind and CSS modules, it depends what you mean by that. I don't have separate CSS modules in my project, except for global styles (including tailwind style reset i.e. preflight, which is unavoidable).

I prefer getting computed classes in the resulting HTML. It feels more efficient/compact and I would imagine results in less CSS overall. Maybe that's just a personal preference.

Using classes makes the usage of tailwind-merge unnecessary, so:

  1. There is no risk into running in edge cases.
  2. There is no bundle size increase.

On the other hand, it does add computation to generate the classes from the tags.

@Mad-Kat
Copy link
Contributor

Mad-Kat commented Mar 23, 2025

Currently the API does not allow writing such a helper but I believe it is possible to add it to next-yak.
What do you think @Mad-Kat?

Yes I think that should be possible. In general I agree very much with your statement.

About combining Tailwind and CSS modules, it depends what you mean by that. I don't have separate CSS modules in my project, except for global styles (including tailwind style reset i.e. preflight, which is unavoidable).

If you would use next-yak, you would have CSS modules, as we transpile to it (to benefit from the existing tools around it).

I prefer getting computed classes in the resulting HTML. It feels more efficient/compact and I would imagine results in less CSS overall. Maybe that's just a personal preference.

The tailwind compiler is smart and tries to remove all classes from tailwind that aren't needed in your codebase and that reduces the whole size by a huge margin. Additionally the idea of "utility CSS" is to avoid specialized classes for certain aspects and should result in less CSS LOC overall in bigger projects.

Using classes makes the usage of tailwind-merge unnecessary, so:
There is no risk into running in edge cases.
There is no bundle size increase.
On the other hand, it does add computation to generate the classes from the tags.

You're right. If you use our css tag (without the atoms function) you will get one class out of it, as we can statically determine your styles. If you instead use the atoms function (inside our css tag or in the CSS prop once available), you opt in for toggling the provided tailwind classes either per default or when your props change. We don't do anything fancy there, as we can't optimize anything statically without getting tailwind specific logic in our code, which is something we don't want.

So this code:

styled.div`
  ${atoms("bg-black mb-4")}
`;

Would result in this HTML

<div class="bg-black mb-4" />

@therealgilles
Copy link
Author

therealgilles commented Mar 23, 2025

I understand your point of view. Philosophical opinions are many and diverse when it comes to atom-based CSS and CSS-in-JS, such as:

and they keep evolving. I can also imagine why the authors of Tailwind would want to see their utility classes in the resulting HTML instead of CSS module classes.

I don't work on any large projects with Tailwind. Mine are mostly small to medium. I mostly see Tailwind as a way to make writing CSS both easier and better.

From a DX perspective, it also makes finding the corresponding code super easy as I can just copy a few of the utility classes (present in data-tw during development with twin.macro) and do a search in VSCode.

With your proposal, I don't see a solution that does not involve tailwind-merge, apart from managing the Tailwind class priorities myself, or never passing "dynamic"* atoms between components (some people may see that as an anti-pattern). Having to use tailwind-merge at runtime feels like a big disadvantage to me. Without it, the runtime JS just has to switch between classes or different CSS variable values based on props, way more efficient.

Here is an example of how I use dynamic* atoms between components:
I have a NextJS-base project that uses a WordPress backend. Most pages have a header section with a featured image and an overlay to display a page title. The page data (including the image info) is fetched through GraphQL. The styling of that image varies by page. I have a single page configuration file where I list all the routes with specific image and image overlay styling for each route (image horizontal/vertical alignment, overlay opacity...). I could add props to list and choose from all the possibilities and then translate that into CSS, but using Tailwind atoms directly feels more efficient, straightforward, and flexible.

I'm not on X but I read that many people disagree with Adam Wathan's position on @apply. The (bad) example he cites, here, shows @apply used on non-utility classes, so that seems like a totally separate issue. I'm also not quite asking for @apply, even though in effect I understand it would be processing atoms under the hood, similar to what @apply does.

In any case, it is obviously up to you to decide what is best for next-yak. Thank you for making this tool available to people like me.

(*) By "dynamic", I mean conditional.

@therealgilles
Copy link
Author

I just gave tailwind-merge a go and it's not doing what I expect. This:

  <div className={twMerge('bg-black', 'bg-opacity-50')} />

gets merged as:

   <div className={'bg-opacity-50'} />

which only sets a CSS variable, so in effect does nothing, whereas twin.macro generates:

  .tw-oqf9q5 {
    --tw-bg-opacity: 1;
    background-color: rgb(0 0 0 / var(--tw-bg-opacity, 1));
    --tw-bg-opacity: 0.5;
  }

which generates a 50% opacity black overlay. Of course I could replace bg-opacity-50 with bg-black/50, but I feel like I'm losing something important in the merging process.

@jantimon
Copy link
Collaborator

Correct - the tailwind-merge behavior with bg-black and bg-opacity-50 is not ideal..

I'm curious though - if you switched the order in the twin.macro case to bg-opacity-50 bg-black - would you get a different result? I suspect it would break bg-opacity-50 and use 100% opacity

.tw-oqf9q5 {
  --tw-bg-opacity: 0.5;
  --tw-bg-opacity: 1;
  background-color: rgb(0 0 0 / var(--tw-bg-opacity, 1));
}

Off-topic: in next-yak you could just write css={css`background:#0007`}

But you are right runtime class merging (tailwind-merge) is slower than build time merging..

I am just not sure that next-yak users might expect tailwind classes to be compiled especially given that tailwind recommends utility classes in HTML instead

CSS merging can also cause issues with order (especially in the current situation where CSS ordering is broken in next.js on production vercel/next.js#72846).
If we generate the css classes we also generate the css specificity for the user - so if the order goes wrong people might blame us for tailwind not to work correctly

In the end next-yak is a CSS-in-JS solution and we want to make a good tradeoff between supporting tailwind for great DX while avoiding too much complexity by becoming a tailwind compiler engine

@therealgilles
Copy link
Author

I'm curious though - if you switched the order in the twin.macro case to bg-opacity-50 bg-black - would you get a different result? I suspect it would break bg-opacity-50 and use 100% opacity

That's correct but that's what I would expect i.e. the latter utility class takes precedence if it sets the same CSS properties.

I am just not sure that next-yak users might expect tailwind classes to be compiled especially given that tailwind recommends utility classes in HTML instead

Ideally users would be able to choose. Or next-yak would leave the door open for a plugin to come in and compile the tailwind classes.

If we generate the css classes we also generate the css specificity for the user - so if the order goes wrong people might blame us for tailwind not to work correctly

Sure but the non-atom part of next-yak does that already, no?

I took a look at the nextjs bug report and I'm actually not sure this could happen with twin.macro or what I propose. Each component only gets a single class, therefore only the order of global CSS vs local CSS matters, and they would hopefully be in separate stylesheets.

Anyway, I'm just here sharing my preference – mostly based on my experience with twin.macro. I wish I could just keep using it and that it would work with Rust-based tooling like Turbopack. Right now, I have two different versions of tailwind installed due to twin.macro referencing internal functions of v3 that don't exist in v4 anymore. Obviously not a maintainable workflow going forward, so looking at alternatives.

Do what you think is best for next-yak :)

@jantimon
Copy link
Collaborator

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging a pull request may close this issue.

3 participants