Tailwind Merge with Colors on Text and Text Shadows

tl;dr I have a text-shadow utility class in my tailwind.config.ts that allows me to change both the dimensions and colors of the shadow. I’m using Tailwind Merge, I can’t figure out how to stop text-shadow-{size/color} from conflicting with text-{color}.

The Problem

Often in CSS, using text shadows is very helpful for adding cool designs around text or even adding contrast for the text, rather than using a drop shadow. I’ve been created a text-shadow utility class to my Tailwind Config a while ago, and it works great, until I use it on a component that utilizes Tailwind Merge. Tailwind Merge is a great package, but—when using custom utility classes—it can get confused.

The Solution

Naturally, my goal was to use extendTailwindMerge to correct this issue. The documentation on configuring Tailwind Merge is well detailed, but since it only gives foo, bar, and baz for examples, I was a bit confused how to do something specific.

The Ask

Please look at my tailwind.config.ts and custom twMerge() function and let me know if you have any ideas. Thanks!

The Code

// tailwind.config.ts

// * Types
import type { Config } from 'tailwindcss'
import type { CSSRuleObject, ThemeConfig } from 'tailwindcss/types/config'

/**
 * ### Decimal Alpha to HEX
 * - Converts an RGB decimal alpha value to hexidecimal alpha format
 * @param decimalAlpha
 * @returns
 */
export function decimalAlphaToHex(decimalAlpha: number): string {
  // Ensure the input is within the valid range
  if (decimalAlpha < 0 || decimalAlpha > 1)
    throw new Error('Decimal alpha value must be between 0 and 1')

  // Convert decimal alpha to a hexadecimal value
  const alphaHex = Math.floor(decimalAlpha * 255)
    .toString(16)
    .toUpperCase()

  // Ensure the hexadecimal value is two digits long (e.g., 0A instead of A)
  if (alphaHex.length < 2) {
    return '0' + alphaHex
  } else {
    return alphaHex
  }
}

type GetTheme = <
  TDefaultValue =
    | Partial<
        ThemeConfig & {
          extend: Partial<ThemeConfig>
        }
      >
    | undefined,
>(
  path?: string | undefined,
  defaultValue?: TDefaultValue | undefined,
) => TDefaultValue

// * Plugins
import plugin from 'tailwindcss/plugin'
import headlessui from '@headlessui/tailwindcss'

// @ts-ignore
import { default as flattenColorPalette } from 'tailwindcss/lib/util/flattenColorPalette'

const config: Config = {
  content: [
    './components/**/*.{js,ts,jsx,tsx,mdx}',
    './app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    textShadow: {
      sm: '0 0 0.125rem var(--tw-text-shadow, hsl(0 0% 0% / 0.25))',
      DEFAULT: '0 0 0.25rem var(--tw-text-shadow, hsl(0 0% 0% / 0.25))',
      md: '0 0 0.5rem var(--tw-text-shadow, hsl(0 0% 0% / 0.25))',
      lg: '0 0 0.75rem var(--tw-text-shadow, hsl(0 0% 0% / 0.25))',
      xl: '0 0 1rem var(--tw-text-shadow, hsl(0 0% 0% / 0.25))',
      '2xl': '0 0 2rem var(--tw-text-shadow, hsl(0 0% 0% / 0.25))',
      '3xl': '0 0 3rem var(--tw-text-shadow, hsl(0 0% 0% / 0.25))',
      none: 'none',
    },
  },
  plugins: [
    plugin(function ({ matchUtilities, theme }) {
      const colors: { [key: string]: string } = {},
        opacities: { [key: string]: string } = flattenColorPalette(
          theme('opacity'),
        ),
        opacityEntries = Object.entries(opacities)

      Object.entries(flattenColorPalette(theme('colors'))).forEach((color) => {
        const [key, value] = color

        if (typeof key !== 'string' || typeof value !== 'string') return null

        colors[key] = value.replace(' / <alpha-value>', '')

        if (value.startsWith('#') && value.length === 7)
          opacityEntries.forEach(([opacityKey, opacityValue]) => {
            colors[`${key}/${opacityKey}`] = `${value}${decimalAlphaToHex(
              Number(opacityValue),
            )}`
          })

        if (value.startsWith('#') && value.length === 4)
          opacityEntries.forEach(([opacityKey, opacityValue]) => {
            colors[`${key}/${opacityKey}`] = `${value}${value.slice(
              1,
            )}${decimalAlphaToHex(Number(opacityValue))}`
          })

        if (value.startsWith('rgb') || value.startsWith('hsl'))
          opacityEntries.forEach(([opacityKey, opacityValue]) => {
            colors[`${key}/${opacityKey}`] = `${value.slice(
              0,
              -1,
            )} / ${opacityValue})`.replace(' / <alpha-value>', '')
          })
      })

      matchUtilities(
        {
          'text-shadow': (value) => {
            const cssProperties: CSSRuleObject = {}

            if (
              typeof value === 'string' &&
              (value.startsWith('#') ||
                value.startsWith('rgb') ||
                value.startsWith('hsl'))
            ) {
              cssProperties['--tw-text-shadow-color'] = value
            } else {
              cssProperties['text-shadow'] = value
            }

            return cssProperties
          },
        },
        { values: { ...theme('textShadow'), ...colors } },
      )
    }),
  ],
}

export default config

My Best Attempt

// utils/custom-tailwind-merge.ts

import { extendTailwindMerge } from 'tailwind-merge'
import colors from 'tailwindcss/colors'

const colorList: { [key: string]: string[] }[] = []

Object.entries(colors).forEach(([colorName, valueList]) => {
  if (
    colorName === 'inherit' ||
    colorName === 'transparent' ||
    colorName === 'white' ||
    colorName === 'black'
  )
    return
  return colorList.push({ [colorName]: Object.keys(valueList) })
})

type AdditionalClassGroupIds = 'text-shadow'

export const twMerge = extendTailwindMerge<AdditionalClassGroupIds>({
  extend: {
    classGroups: {
      'text-shadow': [
        'sm',
        'DEFAULT',
        'md',
        'lg',
        'xl',
        '2xl',
        '3xl',
        'none',
        ...colorList,
        'transparent',
        'white',
        'black',
      ],
    },
  },
})
// components/link.tsx

import type { LinkProps } from '@/typings/components'
import { twMerge } from '@/utils/custom-tailwind-merge'

export default function Link({ children, className }: LinkProps) {
  const defaultClasses = 'text-blue-500'

  return (
    <a className={twMerge(defaultClasses, className)}>{children}</a>
  )
}