npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

nestable-tailwind-variants

v0.4.4

Published

Tailwind CSS variant library with nestable conditions for compound styling instead of flat compoundVariants.

Readme

nestable-tailwind-variants

Tailwind CSS variant library with nestable conditions for compound styling instead of flat compoundVariants.

Inspired by React Spectrum's conditional styles and Tailwind Variants.

Why nestable-tailwind-variants?

With Tailwind Variants, combining variants with React Aria Components render props like isHovered and isPressed requires compoundVariants. This quickly becomes verbose as combinations grow:

import { tv } from 'tailwind-variants';

const button = tv({
  base: 'px-4 py-2 rounded',
  variants: {
    variant: {
      primary: 'bg-blue-500 text-white',
      secondary: 'bg-gray-200 text-gray-800',
    },
    isHovered: { true: '' },
    isPressed: { true: '' },
  },
  compoundVariants: [
    { variant: 'primary', isHovered: true, class: 'bg-blue-600' },
    { variant: 'primary', isPressed: true, class: 'bg-blue-700' },
    { variant: 'secondary', isHovered: true, class: 'bg-gray-300' },
    // ... one entry for every variant × condition combination
  ],
});

button({ variant: 'primary', isHovered: true });
// => 'px-4 py-2 rounded bg-blue-500 text-white bg-blue-600'

nestable-tailwind-variants solves this by letting you nest conditions directly inside variants, keeping related logic together:

import { ntv } from 'nestable-tailwind-variants';

interface ButtonProps {
  variant: 'primary' | 'secondary';
  isHovered?: boolean;
  isPressed?: boolean;
}

const button = ntv<ButtonProps>({
  $base: 'px-4 py-2 rounded',
  variant: {
    primary: {
      $default: 'bg-blue-500 text-white',
      isHovered: 'bg-blue-600',
      isPressed: 'bg-blue-700',
    },
    secondary: {
      $default: 'bg-gray-200 text-gray-800',
      isHovered: 'bg-gray-300',
    },
  },
});

button({ variant: 'primary', isHovered: true });
// => 'px-4 py-2 rounded bg-blue-600'

Installation

npm install nestable-tailwind-variants

Core Concepts

$base - Always-applied styles

The $base property defines styles that are always applied at that level. It can be used at the top level or nested inside variants and conditions:

interface CardProps {
  variant?: 'elevated' | 'flat';
}

const card = ntv<CardProps>({
  $base: 'rounded-lg shadow-md p-4',
  variant: {
    elevated: 'shadow-xl',
    flat: 'shadow-none',
  },
});

card();
// => 'rounded-lg shadow-md p-4'

card({ variant: 'elevated' });
// => 'rounded-lg shadow-xl p-4'
// Note: shadow-md is replaced by shadow-xl via tailwind-merge

When nested inside a variant or condition, $base applies whenever that context is entered:

interface ButtonProps {
  variant?: 'primary' | 'secondary';
  size?: 'sm' | 'lg';
}

const button = ntv<ButtonProps>({
  $base: 'px-4 py-2',
  variant: {
    primary: {
      $base: 'bg-blue-500 text-white', // Applied when variant='primary'
      size: {
        sm: 'text-sm',
        lg: 'text-lg',
      },
    },
    secondary: 'bg-gray-500',
  },
});

button({ variant: 'primary' });
// => 'px-4 py-2 bg-blue-500 text-white'

button({ variant: 'primary', size: 'sm' });
// => 'px-4 py-2 bg-blue-500 text-white text-sm'

$default - Fallback styles

Use $default for styles applied when no boolean conditions (isXxx/allowsXxx) match at that level. Variant matches do not suppress $default:

interface ButtonProps {
  variant?: 'primary' | 'secondary';
  isPressed?: boolean;
}

const button = ntv<ButtonProps>({
  $base: 'px-4 py-2 rounded',
  $default: 'bg-gray-100', // Applied when no boolean conditions match
  isPressed: 'bg-gray-300',
  variant: {
    $default: 'text-gray-800', // Applied when variant is not provided
    primary: 'text-white',
    secondary: 'text-gray-800',
  },
});

button();
// => 'px-4 py-2 rounded bg-gray-100 text-gray-800'

button({ variant: 'primary' });
// => 'px-4 py-2 rounded bg-gray-100 text-white'
// Note: top-level $default is still applied because variant matches don't suppress it

button({ isPressed: true });
// => 'px-4 py-2 rounded bg-gray-300 text-gray-800'
// Note: top-level $default is NOT applied because isPressed matches

Variants - String-based selection

Define variants as objects mapping variant values to class names. Use $default inside a variant to define fallback styles when no value is provided:

interface BadgeProps {
  size?: 'sm' | 'md' | 'lg';
  color?: 'info' | 'success' | 'warning';
}

const badge = ntv<BadgeProps>({
  $base: 'px-2 py-1 rounded',
  size: {
    $default: 'text-sm px-2', // Applied when size is not provided
    sm: 'text-xs px-1.5',
    md: 'text-sm px-2',
    lg: 'text-base px-3',
  },
  color: {
    info: 'bg-blue-100 text-blue-800',
    success: 'bg-green-100 text-green-800',
    warning: 'bg-yellow-100 text-yellow-800',
  },
});

badge();
// => 'px-2 py-1 rounded text-sm px-2'

badge({ size: 'lg', color: 'success' });
// => 'py-1 rounded text-base px-3 bg-green-100 text-green-800'

Boolean Conditions - is[A-Z] / allows[A-Z] patterns

Keys matching is[A-Z]* or allows[A-Z]* are treated as boolean conditions:

interface InputProps {
  isFocused?: boolean;
  isDisabled?: boolean;
  isInvalid?: boolean;
  allowsClearing?: boolean;
}

const input = ntv<InputProps>({
  $base: 'border rounded px-3 py-2',
  $default: 'border-gray-300', // Applied when no boolean conditions match
  isFocused: 'ring-2 ring-blue-500 border-blue-500',
  isDisabled: 'bg-gray-100 text-gray-400 cursor-not-allowed',
  isInvalid: 'border-red-500 text-red-600',
  allowsClearing: 'pr-8',
});

input();
// => 'border rounded px-3 py-2 border-gray-300'

input({ isFocused: true });
// => 'border rounded px-3 py-2 ring-2 ring-blue-500 border-blue-500'
// Note: $default is NOT applied because isFocused matches

input({ isDisabled: true, isInvalid: true });
// => 'border rounded px-3 py-2 bg-gray-100 text-gray-400 cursor-not-allowed border-red-500 text-red-600'

Nested Conditions

Conditions can be nested inside variants or boolean conditions to any depth:

interface ChipProps {
  variant?: 'filled' | 'outlined';
  isSelected?: boolean;
  isDisabled?: boolean;
}

const chip = ntv<ChipProps>({
  $base: 'inline-flex items-center rounded-full px-3 py-1',
  variant: {
    filled: {
      $default: 'bg-gray-200 text-gray-800',
      isSelected: 'bg-blue-500 text-white',
      isDisabled: {
        $default: 'bg-gray-100 text-gray-400',
        isSelected: 'bg-blue-200 text-blue-400', // Selected but disabled
      },
    },
    outlined: {
      $default: 'border border-gray-300 text-gray-800',
      isSelected: 'border-blue-500 text-blue-500',
    },
  },
});

chip({ variant: 'filled' });
// => 'inline-flex items-center rounded-full px-3 py-1 bg-gray-200 text-gray-800'

chip({ variant: 'filled', isSelected: true });
// => 'inline-flex items-center rounded-full px-3 py-1 bg-blue-500 text-white'

chip({ variant: 'filled', isDisabled: true });
// => 'inline-flex items-center rounded-full px-3 py-1 bg-gray-100 text-gray-400'

chip({ variant: 'filled', isDisabled: true, isSelected: true });
// => 'inline-flex items-center rounded-full px-3 py-1 bg-blue-200 text-blue-400'

Composing Styles

Merge multiple style functions into one. Later functions take precedence:

import { ntv, mergeNtv } from 'nestable-tailwind-variants';

interface BaseButtonProps {
  size?: 'sm' | 'md';
}

const baseButton = ntv<BaseButtonProps>({
  $base: 'rounded font-medium transition-colors',
  size: {
    sm: 'px-3 py-1.5 text-sm',
    md: 'px-4 py-2 text-base',
  },
});

interface ColoredButtonProps {
  variant?: 'primary' | 'secondary';
}

const coloredButton = ntv<ColoredButtonProps>({
  variant: {
    primary: 'bg-blue-500 text-white hover:bg-blue-600',
    secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
  },
});

const button = mergeNtv(baseButton, coloredButton);

button({ size: 'md', variant: 'primary' });
// => 'rounded font-medium transition-colors px-4 py-2 text-base bg-blue-500 text-white hover:bg-blue-600'

Passing Additional Classes

Pass additional classes using class or className:

interface BoxProps {
  variant?: 'primary' | 'secondary';
}

const box = ntv<BoxProps>({
  $base: 'p-4 rounded',
  variant: {
    primary: 'bg-blue-500',
    secondary: 'bg-gray-200',
  },
});

box({ variant: 'primary', class: 'mt-4 p-8' });
// => 'rounded bg-blue-500 mt-4 p-8'
// Note: p-8 overrides p-4 via tailwind-merge

box({ variant: 'secondary', className: 'shadow-lg' });
// => 'p-4 rounded bg-gray-200 shadow-lg'

React Aria Components Integration

nestable-tailwind-variants is designed to work seamlessly with React Aria Components render props:

import {
  Button as RACButton,
  composeRenderProps,
  type ButtonProps as RACButtonProps,
  type ButtonRenderProps,
} from 'react-aria-components';
import { ntv } from 'nestable-tailwind-variants';

interface ButtonStyleProps {
  variant?: 'primary' | 'secondary';
  size?: 'sm' | 'md' | 'lg';
}

const button = ntv<ButtonRenderProps & ButtonStyleProps>({
  $base:
    'inline-flex items-center justify-center rounded-md px-4 py-2 font-medium transition-colors',
  variant: {
    primary: {
      $default: 'bg-blue-500 text-white',
      isHovered: 'bg-blue-600',
      isPressed: 'bg-blue-700',
      isFocusVisible: 'ring-2 ring-blue-500 ring-offset-2',
    },
    secondary: {
      $default: 'bg-gray-200 text-gray-800',
      isHovered: 'bg-gray-300',
      isPressed: 'bg-gray-400',
      isFocusVisible: 'ring-2 ring-gray-500 ring-offset-2',
    },
  },
  size: {
    sm: 'h-8 px-3 text-sm',
    md: 'h-10 px-4 text-base',
    lg: 'h-12 px-6 text-lg',
  },
  isDisabled: 'opacity-50 cursor-not-allowed',
});

function Button({
  variant = 'primary',
  size = 'md',
  children,
  ...props
}: RACButtonProps & ButtonStyleProps) {
  return (
    <RACButton
      {...props}
      className={composeRenderProps(props.className, (className, renderProps) =>
        // renderProps includes isHovered, isPressed, isFocusVisible, isDisabled
        button({ ...renderProps, variant, size, className }),
      )}
    >
      {children}
    </RACButton>
  );
}

Type Safety

The recommended approach is to define a props type and pass it as a type argument to ntv. This provides clear documentation, better IDE support, and ensures type safety:

interface ButtonProps {
  variant: 'primary' | 'secondary';
  size: 'sm' | 'md' | 'lg';
  isDisabled: boolean;
}

const button = ntv<ButtonProps>({
  $base: 'rounded',
  variant: {
    primary: 'bg-blue-500',
    secondary: 'bg-gray-200',
  },
  size: {
    sm: 'text-sm',
    md: 'text-base',
    lg: 'text-lg',
  },
  isDisabled: 'opacity-50',
});

// All props are required - props parameter is mandatory
button({ variant: 'primary', size: 'lg', isDisabled: false }); // ✅ OK
button({ variant: 'tertiary' }); // ❌ Error: 'tertiary' is not assignable
button({ variant: 'primary' }); // ❌ Error: missing 'size' and 'isDisabled'

Use optional properties (?) when you want to allow calling the style function without providing all props:

interface ButtonProps {
  variant?: 'primary' | 'secondary';
  size?: 'sm' | 'md' | 'lg';
  isDisabled?: boolean;
}

const button = ntv<ButtonProps>({
  $base: 'rounded',
  variant: {
    primary: 'bg-blue-500',
    secondary: 'bg-gray-200',
  },
  size: {
    sm: 'text-sm',
    md: 'text-base',
    lg: 'text-lg',
  },
  isDisabled: 'opacity-50',
});

// All props are optional - props parameter can be omitted
button(); // ✅ OK
button({ variant: 'primary' }); // ✅ OK
button({ variant: 'primary', size: 'lg' }); // ✅ OK

Omitting the type argument when the scheme has keys disables type checking. Provide explicit type arguments when possible for better type safety.

Options

ntv accepts an options object as the second argument. For mergeNtv, use mergeNtvWithOptions to pass options.

Disabling tailwind-merge

By default, tailwind-merge is used to resolve class conflicts. To disable it, pass { twMerge: false }.

For ntv:

const styles = ntv(
  {
    $base: 'p-4',
  },
  { twMerge: false },
);

styles({ class: 'p-8' });
// => 'p-4 p-8' (no merge, both classes kept)

For mergeNtvWithOptions:

const styles = mergeNtvWithOptions(baseStyles, overrideStyles)({ twMerge: false });

Custom tailwind-merge configuration

If you've extended Tailwind with custom classes (e.g., custom font sizes via @theme), you need to tell tailwind-merge about them so it can resolve conflicts correctly:

/* app.css */
@theme {
  --font-size-huge: 4rem;
}

For ntv:

const heading = ntv(
  { $base: 'text-huge' },
  {
    twMergeConfig: {
      extend: {
        classGroups: {
          'font-size': [{ text: ['huge'] }],
        },
      },
    },
  },
);

heading({ class: 'text-6xl' });
// => 'text-6xl' (text-huge is correctly replaced by text-6xl)

For mergeNtvWithOptions:

const styles = mergeNtvWithOptions(
  baseStyles,
  overrideStyles,
)({
  twMergeConfig: {
    extend: {
      classGroups: {
        'font-size': [{ text: ['huge'] }],
      },
    },
  },
});

Without this configuration, tailwind-merge wouldn't recognize text-huge as a font-size class and would keep both text-huge and text-6xl.

Pre-configured factories

Use createNtv or createMergeNtv to create functions with shared options:

import { createNtv, createMergeNtv, type TwMergeConfig } from 'nestable-tailwind-variants';

const twMergeConfig: TwMergeConfig = {
  extend: {
    classGroups: {
      'font-size': [{ text: ['huge', 'tiny'] }],
    },
  },
};

const ntv = createNtv({ twMergeConfig });
const mergeNtv = createMergeNtv({ twMergeConfig });

Tooling

Tailwind CSS IntelliSense

Add to .vscode/settings.json for Tailwind CSS IntelliSense class autocomplete:

{
  "tailwindCSS.experimental.classRegex": [
    ["ntv(?:<[\\s\\S]*?>)?\\s*\\(\\s*\\{([\\s\\S]*?)\\}\\s*\\)", "[\"'`]([^\"'`]*)[\"'`]"]
  ]
}

Note: tailwindCSS.classFunctions does not work with generics (e.g., ntv<Props>({...})). Use experimental.classRegex instead. See issue #2 and tailwindcss-intellisense#1539 for details.

prettier-plugin-tailwindcss

Add to your Prettier config for prettier-plugin-tailwindcss automatic class sorting:

{
  "tailwindFunctions": ["ntv"]
}

eslint-plugin-better-tailwindcss

Add to your ESLint config for eslint-plugin-better-tailwindcss linting:

import { getDefaultCallees } from 'eslint-plugin-better-tailwindcss/defaults';

export default [
  {
    settings: {
      'better-tailwindcss': {
        callees: [...getDefaultCallees(), ['ntv', [{ match: 'objectValues' }]]],
      },
    },
  },
];

API Reference

Functions

| Function | Description | | -------------------------------------------- | ------------------------------------------------------- | | ntv(scheme, options?) | Create a style function from a scheme | | createNtv(options) | Create a pre-configured ntv with default options | | mergeNtv(...styleFns) | Merge multiple style functions into one | | mergeNtvWithOptions(...styleFns)(options?) | Merge style functions with custom options | | createMergeNtv(options) | Create a pre-configured mergeNtv with default options |

Types

| Type | Description | | --------------- | ------------------------------------------------------------------------------------------- | | ClassValue | Valid class value (string, array, or object) | | ClassProp | Props for runtime class override ({ class?: ClassValue } or { className?: ClassValue }) | | NtvOptions | Options for ntv functions ({ twMerge?: boolean; twMergeConfig?: TwMergeConfig }) | | TwMergeConfig | Configuration object for tailwind-merge |

Scheme Properties

| Property | Description | | -------------- | ------------------------------------------------------------------------------------------- | | $base | Classes always applied at that level (can be used at top-level or nested within conditions) | | $default | Fallback classes when no conditions match at that level | | is[A-Z]* | Boolean condition (e.g., isSelected, isDisabled) | | allows[A-Z]* | Boolean condition (e.g., allowsRemoving) | | [key] | Variant object mapping values to classes |

License

MIT