React TWC を触ってみる
ドキュメント
TWC は、簡単にいうと1行で Tailwind CSS を用いたスタイル付きコンポーネントを作成することができるライブラリ。
簡単なカードコンポーネントを React と Tailwind CSS を用いて実装する場合、通常以下のようになる。
import * as React from "react";
import clsx from "clsx";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={clsx(
"rounded-lg border bg-slate-100 text-white shadow-sm",
className,
)}
{...props}
/>
));
一方、TWC を利用すれば以下のように1行で書くことができる。
import { twc } from "react-twc";
const Card = twc.div`rounded-lg border bg-slate-100 text-white shadow-sm`;
めちゃくちゃ簡潔に書けるね。
RSC との互換性問題ないようだし、サードパーティー製のコンポーネント(例えば、Radiix など)でも利用できるのは非常にいい。
内部的な処理が気になったので、調べてみた。 ここに処理が書いてあった。
export const createTwc = <TCompose extends AbstractCompose = typeof clsx>(
config: Config<TCompose> = {},
) => {
const compose = config.compose || clsx;
const defaultShouldForwardProp =
config.shouldForwardProp || ((prop) => prop[0] !== "$");
const wrap = (Component: React.ElementType) => {
const createTemplate = (
attrs?: Attributes,
shouldForwardProp = defaultShouldForwardProp,
) => {
const template = (
stringsOrFn: TemplateStringsArray | Function,
...values: any[]
) => {
const isClassFn = typeof stringsOrFn === "function";
const tplClassName =
!isClassFn && String.raw({ raw: stringsOrFn }, ...values);
return React.forwardRef((p: any, ref) => {
const { className: classNameProp, asChild, ...rest } = p;
const rp =
typeof attrs === "function" ? attrs(p) : attrs ? attrs : {};
const fp = filterProps({ ...rp, ...rest }, shouldForwardProp);
const Comp = asChild ? Slot : Component;
const resClassName = isClassFn ? stringsOrFn(p) : tplClassName;
return (
<Comp
ref={ref}
className={
typeof resClassName === "function"
? (renderProps: any) =>
compose(
resClassName(renderProps),
typeof classNameProp === "function"
? classNameProp(renderProps)
: classNameProp,
)
: compose(resClassName, classNameProp)
}
{...fp}
/>
);
});
};
template.transientProps = (
fnOrArray: string[] | ((prop: string) => boolean),
) => {
const shouldForwardProp =
typeof fnOrArray === "function"
? (prop: string) => !fnOrArray(prop)
: (prop: string) => !fnOrArray.includes(prop);
return createTemplate(attrs, shouldForwardProp);
};
if (attrs === undefined) {
template.attrs = (attrs: Attributes) => {
return createTemplate(attrs);
};
}
return template;
};
return createTemplate();
};
return new Proxy(
(component: React.ComponentType) => {
return wrap(component);
},
{
get(_, name) {
return wrap(name as keyof JSX.IntrinsicElements);
},
},
) as any as Twc<TCompose>;
};const compose = config.compose || clsx;
compose は クラス名を結合するための関数やけど、デフォルトでは clsx になっている。
別の結合関数も、config で渡せるようになってるっぽい。
const template = (
stringsOrFn: TemplateStringsArray | Function,
...values: any[]
) => {
const isClassFn = typeof stringsOrFn === "function";
const tplClassName =
!isClassFn && String.raw({ raw: stringsOrFn }, ...values);
return React.forwardRef((p: any, ref) => {
const { className: classNameProp, asChild, ...rest } = p;
const rp =
typeof attrs === "function" ? attrs(p) : attrs ? attrs : {};
const fp = filterProps({ ...rp, ...rest }, shouldForwardProp);
const Comp = asChild ? Slot : Component;
const resClassName = isClassFn ? stringsOrFn(p) : tplClassName;
return (
<Comp
ref={ref}
className={
typeof resClassName === "function"
? (renderProps: any) =>
compose(
resClassName(renderProps),
typeof classNameProp === "function"
? classNameProp(renderProps)
: classNameProp,
)
: compose(resClassName, classNameProp)
}
{...fp}
/>
);
});
};
template 関数で、コンポーネントを返してる。
const Comp = asChild ? Slot : Component;
ここもポイントやな。asChild が props として渡ってきた場合に、Slot と Componennt を切り替えれるようになってる。あとで試してみよ。
あと、forwardRef でラップしてるので、ref を渡すこともできる。
なんとなく内部構造は理解した
インストール
npm i react-twc
実際に使ってみる。
Card コンポーネントで色々試してみようか。
import { twc } from "react-twc"
export const Card = twc.div`rounded-lg border bg-slate-100 shadow-lg p-4`
import { Card } from "./_components/Card";
export default function Home() {
return (
<div className="mx-4 space-y-4">
<p>Card Sample</p>
<Card>
<p>Card Content</p>
</Card>
</div>
);
}

描画されてる。
asChild を props に渡してみる。
p タグは、section タグでラップしておく。
import { Card } from "./_components/Card";
export default function Home() {
return (
<div className="mx-4 space-y-4">
<p>Card Sample</p>
<Card asChild>
<section>
<p>Card Content</p>
</section>
</Card>
</div>
);
}

div から section に変わってるので、asChild が問題なく動作している。
className を渡せるのか確認する
import { Card } from "./_components/Card";
export default function Home() {
return (
<div className="mx-4 space-y-4">
<p>Card Sample</p>
<Card className="border-2 border-red-400">
<p>Card Content</p>
</Card>
</div>
);
}

デフォルトだと、クラスのマージができないみたい。
rounded-sm を渡してみると、rounded-lg が残ったまま。
import { Card } from "./_components/Card";
export default function Home() {
return (
<div className="mx-4 space-y-4">
<p>Card Sample</p>
<Card className="border-2 border-red-400 rounded-sm">
<p>Card Content</p>
</Card>
</div>
);
}

マージしたいのであれば、例えば、以下のような tmMerge と clsx を組み合わせたユーティリティ関数を作成し、createTwc の compose に渡す必要がある。
import { twMerge } from "tailwind-merge";
import { ClassValue, clsx } from "clsx";
import { createTwc } from "react-twc";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const twx = createTwc({ compose: cn });
Card コンポーネントを twc から twx に置き換える。
import { twx } from "../utils";
export const Card = twx.div`rounded-lg border bg-slate-100 shadow-lg p-4`
再び確認してみる。

ちゃんとマージされてることを確認。
cva を用いた variants を実装してみる
import { VariantProps, cva } from "class-variance-authority"
import { TwcComponentProps, twc } from "react-twc"
const cardVariants = cva("rounded-log border shadow-log p-4", {
variants: {
$color: {
primary: "bg-slate-100 text-white",
secondary: "bg-gray-500"
}
},
defaultVariants: {
$color: "primary"
}
})
type cardProps = TwcComponentProps<"div"> & VariantProps<typeof cardVariants>
export const Card = twc.div<cardProps>(({ $color }) => cardVariants({ $color }))
import { Card } from "./_components/Card";
export default function Home() {
return (
<div className="mx-4 space-y-4">
<p>Card Sample</p>
<Card>
<p>Card Content</p>
</Card>
<Card $color="secondary">
<p>Card Content</p>
</Card>
</div>
);
}

問題ないね。
以下のようなコンポーネントを twc で書き換えるとすると?
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import React from 'react'
const ButtonIconWrapper = ({ children }: { children: React.ReactNode }) => {
return <span className="inline-flex items-center">{children}</span>
}
const buttonVariants = cva(
'disabled:hover inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition duration-200 focus:outline-none focus:ring disabled:cursor-not-allowed disabled:opacity-50 focus:disabled:ring-0',
{
variants: {
variant: {
primary:
'bg-[#3ea8ff] text-white hover:bg-[#0f83fd] focus:ring-[#98c7ff] hover:disabled:bg-[#3ea8ff]',
basic:
'border border-[#d6e3ed] bg-white hover:bg-[#f5fbff] focus:ring-[#bfdcff] hover:disabled:bg-white ',
},
size: {
sm: 'px-3 py-2 text-[0.95rem]',
md: 'px-3.5 py-2.5 text-[0.95rem]',
lg: 'px-4 py-3 text-[0.95rem]',
icon: 'size-10',
},
fullWidth: {
true: 'w-full',
},
radius: {
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
radius: 'sm',
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
startContent?: React.ReactElement
endContent?: React.ReactElement
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
startContent,
endContent,
className,
variant,
size,
radius,
fullWidth,
...props
},
ref,
) => {
return (
<button
className={cn(
buttonVariants({ variant, size, radius, fullWidth, className }),
)}
ref={ref}
type="button"
{...props}
>
{startContent && <ButtonIconWrapper>{startContent}</ButtonIconWrapper>}
{children}
{endContent && <ButtonIconWrapper>{endContent}</ButtonIconWrapper>}
</button>
)
},
)
Button.displayName = 'Button'
export { Button, buttonVariants }
実際に試さないけど、まず twc を用いて Button コンポーネントを作成する。
const Button = twc.button..........
そして、以下のように props を渡せる、ButtonWrapper コンポーネントを作成する。
const ButtonWrapper = ({ startContent }) => {
return (
<Button>
{startContent && ...........}
{children}
</Button>
)
}
おそらくこんな感じで、ラッパーを作成するしかないよな。
二度手間になるし、なんなら可読性も下がる。上記のケースではtwcを使うべきではないかも。
一旦試したいことは試せた。