tiwi
v3.1.0
Published
React library to create components with Tailwind styles baked in.
Downloads
3,261
Maintainers
Readme
tiwi ·

Tiwi is a React library that makes it easy to create components with Tailwind styles baked in. This makes it straightforward to preserve the separation of concern between structure and style, similar to styled-components. It also comes with a powerful variants system for more advanced use cases.
Tiwi was made to power BAQ.
// Don't write this:
<div className=`p-2 rounded bg-neutral-100 hover:bg-white shadow-md ${isUrgent ? "shadow-red-200" : "shadow-neutral-200"}`>
<a className="text-md text-green-400 font-semibold" href={url}>Open in new window</a>
</div>;
//
// Write this instead:
//
const Card = tiwi.div`
p-2
bg-neutral-100
hover:bg-white
shadow-md
shadow-neutral-200
${{
isUrgent: `shadow-red-200`,
}}
`;
const CardLink = tiwi.a`
text-md
text-green-400
font-semibold
`;
<Card $isUrgent={isUrgent}>
<CardLink href={url}>Open in new window</CardLink>
</Card>;Highlights
✅ Works with React on the web, SSR, and React Native. ✅ Full TypeScript compatibility. ✅ Extend existing components. ✅ Flexible variants system.
Table of contents
Getting started
1. Tailwind
Install and configure TailwindCSS in your project.
2. Tiwi
npm install tiwi3. VSCode
For the best experience, install the official Tailwind CSS Intellisense extension.
The following configuration will allow the extension to work with Tiwi:
// As-you-type suggestions inside template strings.
"editor.quickSuggestions": {"strings": "on"},
// Handles `tiwi`…`` and `tiwi.div`…`` via the extension's own parser.
"tailwindCSS.classFunctions": ["tiwi", "tiwi\\.[a-z-]+"],
// classFunctions only fires when the tag name is immediately followed by a
// backtick, so call parens and generics break it. This covers exactly those
// shapes — `tiwi(Base)`, `tiwi.div<V>`, `tiwi(Base)<V>` — and captures the
// whole template so `${{…}}` variant blocks are included too.
"tailwindCSS.experimental.classRegex": [
[
"tiwi(?:\\.[\\w-]+)?(?:\\([^)]*\\)|<)[^`]*(`[\\s\\S]*?`);",
"(?:[\"'`]|}}(?!}))([^\"'`$}]*)[\"'`$]"
]
]Basic usage
Import Tiwi in your component file:
import tiwi from "tiwi";You can now create Tiwi components:
const Header = tiwi.h1`
text-3xl
text-blue-600
`;
const Button = tiwi.button`
rounded
bg-blue-300
`;These can be used like any other component:
<Button />
// Renders as:
// <button class="rounded bg-blue-300" />Classes can still be overridden inline:
<Button className="bg-red-300" />
// Renders as:
// <button class="rounded bg-red-300" />Other props work as expected:
<Button type="submit">Submit</Button>
// Renders as:
// <button class="rounded bg-blue-300" type="submit">Submit</button>Tiwi components can be further extended:
const BigButton = tiwi(Button)`
text-lg
`;
// Renders as:
// <button class="rounded bg-blue-300 text-lg" />When extending, styles can be overwritten:
const RedButton = tiwi(Button)`
bg-red-300
`;
// Renders as:
// <button class="rounded bg-red-300" />Any component with a className prop can be styled:
const SubmitButton: FC<{className?: string}> = props => {
return (
<button className={props.className} type="submit">
Submit
</button>
);
};
const RedSubmitButton = tiwi(SubmitButton)`
bg-red-300
`;
// Renders as:
// <button class="bg-red-300" type="submit">Submit</button>Variants
What makes Tiwi so powerful is the built-in support for variants. It enables the style of a component to be changed along multiple dimensions without creating every permutation separately.
Variant groups expose a prefixed styling prop directly on the component:
const SizeButton = tiwi.button`
m-1
p-2
text-normal
${{
size: {
small: `p-3 text-sm`,
large: `p-5 text-xl`,
},
}}
`;The group name becomes a $-prefixed prop, and its values are inferred from the
definition:
<SizeButton />;
// Renders as:
// <button class="m-1 p-2 text-normal" />
<SizeButton $size="large" />;
// Renders as:
// <button class="m-1 p-5 text-xl" />String variants similarly expose a boolean styling prop:
const Button = tiwi.button`
bg-blue-500
${{
isDisabled: `bg-neutral-200`,
}}
`;
<Button $isDisabled />;Prefixed variant props are used only by Tiwi and are never forwarded to the DOM element or wrapped component. This keeps styling separate from behavioral props:
<Button disabled={isDisabled} $isDisabled={isDisabled} />Multiple variant groups can be declared together:
const FlexButton = tiwi.button`
p-2
text-normal
bg-blue-300
${{
size: {
medium: `p-3 text-lg`,
large: `p-5 text-xl`,
},
intent: {
primary: `bg-green-300`,
critical: `bg-red-300`,
},
}}
`;
<FlexButton $size="medium" $intent="critical" />;
// Renders as:
// <button class="p-3 text-lg bg-red-300" />Defaults can be declared explicitly with $defaultVariants. Default keys and
values are checked against the other variants in the same interpolation:
const Button = tiwi.button`
rounded
${{
$defaultVariants: {
size: "small",
intent: "primary",
isDisabled: false,
},
size: {
small: `p-2 text-sm`,
large: `p-4 text-lg`,
},
intent: {
primary: `bg-blue-500 text-white`,
secondary: `bg-neutral-100 text-neutral-900`,
},
isDisabled: `cursor-not-allowed opacity-50`,
}}
`;
<Button />; // small + primary
<Button $size="large" />; // large + primary
<Button $size={null} />; // no size variant + primaryAn omitted or undefined prop uses its default. An explicit prop overrides it,
while null disables the variant entirely. For boolean variants, false and
null both disable a true default. Defaults never create or forward synthetic
props to wrapped components.
If a variant receives defaults in multiple interpolation blocks, the latest default is used component-wide. Nested Tiwi components keep their own defaults; only a value explicitly passed by the caller is forwarded through the component layers.
When multiple active variants set the same Tailwind property, the variant declared latest in the template wins:
const Button = tiwi.button`
p-2
${{
size: {
large: `p-4 text-lg`,
},
density: {
comfortable: `p-6 leading-loose`,
},
}}
`;
<Button $size="large" $density="comfortable" />;
<Button $density="comfortable" $size="large" />;
// Both render with:
// p-6 text-lg leading-loosedensity is declared after size, so its p-6 replaces p-4. The order of the
JSX props does not affect priority. The same declaration-order rule applies to
legacy variant arrays and maps. An inline className is applied last and can
override both base and variant styles.
Extended Tiwi components follow the same rule: base and extension classes are combined, with conflicting classes from the extension applied later.
The original variants prop is still useful when there is a single string union
that already describes the component's styles. Provide that union as the generic
parameter, then pass its value straight through:
type Size = "small" | "medium" | "large";
const LegacySizeButton = tiwi.button<Size>`
p-2
text-sm
${{
medium: `p-3 text-lg`,
large: `p-5 text-xl`,
}}
`;
const MyComponent: FC<{size?: Size}> = props => {
return <LegacySizeButton variants={props.size}>Continue</LegacySizeButton>;
};variants also accepts an array or boolean map when older code needs to combine
several flat variants. When a direct boolean prop and variants address the same
variant, the direct prop takes precedence.
TypeScript
Tiwi is fully compatible with TypeScript and both props and variants are strongly typed automatically.
Grouped and boolean props are inferred directly from the declaration:
const Button = tiwi.button`
${{
isDisabled: `opacity-50`,
size: {
small: `p-2`,
large: `p-4`,
},
}}
`;
<Button $isDisabled $size="small" />;The generated direct props can be extracted for wrappers and other component types:
import type {VariantPropsOf} from "tiwi";
type ButtonVariants = VariantPropsOf<typeof Button>;
// {$isDisabled?: boolean | null; $size?: "small" | "large" | null}For components using the flat variants API, VariantsOf extracts the string
union instead:
import type {VariantsOf} from "tiwi";
type Size = VariantsOf<typeof LegacySizeButton>;
// "small" | "medium" | "large"React Native
Tiwi is compatible with React Native through Uniwind, which
adds Tailwind className support to React Native components.
On React Native, import Tiwi from tiwi/native. It exposes the same API minus the
DOM intrinsic elements (tiwi.div, tiwi.span, …), which don't exist on native:
import {View, Text} from "react-native";
import tiwi from "tiwi/native";
const Avatar = tiwi(View)`
rounded-full
${{
size: {
small: `size-8`,
large: `size-12`,
},
}}
`;
const Title = tiwi(Text)`
text-neutral-900
dark:text-white
`;
<Avatar $size="large" />;Core React Native components (View, Text, etc.) work out of the box. To style a
third-party component that doesn't natively support className, wrap it with
Uniwind's withUniwind first:
import {withUniwind} from "uniwind";
import {SafeAreaView} from "react-native-safe-area-context";
import tiwi from "tiwi/native";
const Screen = tiwi(withUniwind(SafeAreaView))`
flex-1
bg-white
`;Full example
// tooltip.tsx
import {FC, PropsWithChildren, ReactNode} from "react";
import tiwi from "tiwi";
//
// Props.
//
type TooltipVariant = "normal" | "important";
interface TooltipProps extends PropsWithChildren {
variant?: TooltipVariant;
icon: ReactNode;
}
//
// Style.
//
const Layout = tiwi.div`
flex
flex-row
rounded
p-2
gap-1
bg-neutral-200
${{
variant: {
normal: ``,
important: `bg-red-200`,
},
}}
`;
const Icon = tiwi.div`
size-5
`;
const Text = tiwi.div`
text-neutral-900
font-medium
`;
//
// Component.
//
export const Tooltip: FC<TooltipProps> = props => {
const {variant, icon, children} = props;
return (
<Layout $variant={variant}>
<Icon>{icon}</Icon>
<Text>{children}</Text>
</Layout>
);
};Acknowledgements
This library was inspired by Tailwind-Styled-Component and borrows some of its ideas. It also heavily relies on tailwind-merge for the underlying class manipulation and shares the same limitations.
License
Tiwi is MIT licensed.
