@@ -1016,13 +1016,20 @@ export function parseClass(className: string): ParsedClass {
10161016 * Internal implementation of parseClass
10171017*/
10181018function parseClassImpl ( className : string ) : ParsedClass {
1019- // Check for important modifier
1019+ // Check for important modifier. Supports both Tailwind v3 prefix form
1020+ // (`!p-4`) and v4 suffix form (`p-4!`). Suffix form is checked BEFORE
1021+ // rejecting arbitrary-value brackets so `p-4!` peels the bang off cleanly
1022+ // while `p-[4]!` and `[color:red]!` still work.
10201023 let important = false
10211024 let cleanClassName = className
10221025 if ( className . startsWith ( '!' ) ) {
10231026 important = true
10241027 cleanClassName = className . slice ( 1 )
10251028 }
1029+ else if ( className . endsWith ( '!' ) && className . length > 1 ) {
1030+ important = true
1031+ cleanClassName = className . slice ( 0 , - 1 )
1032+ }
10261033
10271034 // Check for arbitrary properties BEFORE splitting on colons: [color:red], [mask-type:luminance]
10281035 const arbitraryPropMatch = cleanClassName . match ( / ^ \[ ( [ a - z - ] + ) : ( .+ ) \] $ / )
@@ -1040,13 +1047,50 @@ function parseClassImpl(className: string): ParsedClass {
10401047 }
10411048 }
10421049
1050+ // Check for arbitrary-value + arbitrary-modifier shape:
1051+ // text-[14px]/[1.5] font-size with line-height
1052+ // bg-[#FF3E54]/[0.33] background color with arbitrary alpha
1053+ // The main preArbitraryMatch regex below ends on the last `]`, so when
1054+ // there are two bracket pairs it would swallow the slash into the value.
1055+ // Detect the shape first and split it cleanly.
1056+ const preArbitraryWithModifierMatch = cleanClassName . match (
1057+ / ^ ( (?: [ a - z - ] + : ) * ) ( - ? [ a - z - ] + ?) - \[ ( [ ^ \] ] + ) \] \/ \[ ( [ ^ \] ] + ) \] $ / ,
1058+ )
1059+ if ( preArbitraryWithModifierMatch ) {
1060+ const [ , variantPart , utilityRaw , bracketValue , modifier ] = preArbitraryWithModifierMatch
1061+ const variants = variantPart ? variantPart . split ( ':' ) . filter ( Boolean ) : [ ]
1062+ const isNegative = utilityRaw . startsWith ( '-' )
1063+ const utilityName = isNegative ? utilityRaw . slice ( 1 ) : utilityRaw
1064+ let value = convertArbitraryUnderscores ( bracketValue )
1065+ if ( isNegative ) value = value . startsWith ( '-' ) ? value : `-${ value } `
1066+ return {
1067+ raw : className ,
1068+ variants,
1069+ utility : utilityName ,
1070+ // Encode the modifier as `value/modifier` — rules that care (fontSizeRule
1071+ // for line-height, colorRule for alpha) split on the first slash.
1072+ value : `${ value } /${ modifier } ` ,
1073+ important,
1074+ arbitrary : true ,
1075+ }
1076+ }
1077+
10431078 // Check for arbitrary values with brackets BEFORE splitting on colons
10441079 // This handles cases like bg-[url(https://...)] where the URL contains colons
10451080 // Also handles type hints like text-[color:var(--muted)]
1046- const preArbitraryMatch = cleanClassName . match ( / ^ ( (?: [ a - z - ] + : ) * ) ( [ a - z - ] + ?) - \[ ( .+ ) \] $ / )
1081+ const preArbitraryMatch = cleanClassName . match ( / ^ ( (?: [ a - z - ] + : ) * ) ( - ? [ a - z - ] + ?) - \[ ( .+ ) \] $ / )
10471082 if ( preArbitraryMatch ) {
10481083 const variantPart = preArbitraryMatch [ 1 ]
10491084 const variants = variantPart ? variantPart . split ( ':' ) . filter ( Boolean ) : [ ]
1085+
1086+ // Detect a leading `-` on the utility — Tailwind-style negative arbitrary
1087+ // values like `-mt-[20px]` or `-translate-x-[50%]`. Strip the sign from
1088+ // the utility name and fold it onto the value so rules that match
1089+ // `utility === 'mt'` still fire.
1090+ let utilityName = preArbitraryMatch [ 2 ]
1091+ const isNegative = utilityName . startsWith ( '-' )
1092+ if ( isNegative ) utilityName = utilityName . slice ( 1 )
1093+
10501094 let value = convertArbitraryUnderscores ( preArbitraryMatch [ 3 ] )
10511095 let typeHint : string | undefined
10521096
@@ -1059,10 +1103,12 @@ function parseClassImpl(className: string): ParsedClass {
10591103 value = typeHintMatch [ 2 ]
10601104 }
10611105
1106+ if ( isNegative ) value = value . startsWith ( '-' ) ? value : `-${ value } `
1107+
10621108 return {
10631109 raw : className ,
10641110 variants,
1065- utility : preArbitraryMatch [ 2 ] ,
1111+ utility : utilityName ,
10661112 value,
10671113 important,
10681114 arbitrary : true ,
@@ -1088,9 +1134,34 @@ function parseClassImpl(className: string): ParsedClass {
10881134 }
10891135 parts . push ( current )
10901136
1091- const utility = parts [ parts . length - 1 ]
1137+ let utility = parts [ parts . length - 1 ]
10921138 const variants = parts . slice ( 0 , - 1 )
10931139
1140+ // Handle `!` placed between the last variant and the utility
1141+ // (`hover:!bg-red-500`). After splitting on `:`, the bang sits at the
1142+ // start of the utility token. Strip it and flag important. Mirror the
1143+ // trailing form too (`hover:p-4!`) for consistency with the top-of-file
1144+ // bang handling that covered the no-variant case.
1145+ if ( utility . startsWith ( '!' ) && utility . length > 1 ) {
1146+ important = true
1147+ utility = utility . slice ( 1 )
1148+ }
1149+ else if ( utility . endsWith ( '!' ) && utility . length > 1 ) {
1150+ important = true
1151+ utility = utility . slice ( 0 , - 1 )
1152+ }
1153+
1154+ // cleanClassName is used below for the subsequent arbitrary-value /
1155+ // negative-value branches that rebuild the parsed shape from scratch.
1156+ // Keep it in sync with the stripped utility so those branches see the
1157+ // same token.
1158+ if ( variants . length > 0 ) {
1159+ cleanClassName = `${ variants . join ( ':' ) } :${ utility } `
1160+ }
1161+ else {
1162+ cleanClassName = utility
1163+ }
1164+
10941165 // Check for full utility names that should not be split
10951166 const fullUtilityNames = [
10961167 // Display utilities
@@ -1151,6 +1222,9 @@ function parseClassImpl(className: string): ParsedClass {
11511222 // Handle compound utilities with specific prefixes
11521223 // grid-cols-3, grid-rows-2, translate-x-4, etc.
11531224 const compoundPrefixes = [
1225+ // Position utilities
1226+ 'inset-x' ,
1227+ 'inset-y' ,
11541228 // Border side utilities (border-t-0, border-r-2, etc.)
11551229 'border-t' ,
11561230 'border-r' ,
@@ -1250,6 +1324,18 @@ function parseClassImpl(className: string): ParsedClass {
12501324 'rounded-se' ,
12511325 'rounded-es' ,
12521326 'rounded-ee' ,
1327+ // Physical rounded sides + corners (rounded-t-lg, rounded-tr-[6px], etc.).
1328+ // The generator has fast-path lookups for specific size keywords, but
1329+ // these prefixes are needed so arbitrary values and custom sizes fall
1330+ // through to the rule handlers that accept any value.
1331+ 'rounded-t' ,
1332+ 'rounded-r' ,
1333+ 'rounded-b' ,
1334+ 'rounded-l' ,
1335+ 'rounded-tl' ,
1336+ 'rounded-tr' ,
1337+ 'rounded-bl' ,
1338+ 'rounded-br' ,
12531339 'border-opacity' ,
12541340 'ring-opacity' ,
12551341 'stroke-dasharray' ,
@@ -1347,9 +1433,10 @@ function parseClassImpl(className: string): ParsedClass {
13471433 }
13481434
13491435 // Check for color opacity modifiers: bg-blue-500/50, text-red-500/75, bg-white/[0.04]
1350- // Must come before fractional values to avoid conflict
1436+ // Must come before fractional values to avoid conflict.
1437+ // Gradient stops (from/via/to) accept opacity too — `from-red-500/50`.
13511438 const opacityMatch = utility . match ( / ^ ( [ a - z ] + (?: - [ a - z ] + ) * ?) - ( .+ ?) \/ ( \d + | \[ \d * \. ? \d + \] ) $ / )
1352- if ( opacityMatch && [ 'bg' , 'text' , 'border' , 'ring' , 'placeholder' , 'divide' , 'accent' , 'caret' , 'fill' , 'stroke' , 'outline' , 'decoration' , 'shadow' , 'ring-offset' ] . includes ( opacityMatch [ 1 ] ) ) {
1439+ if ( opacityMatch && [ 'bg' , 'text' , 'border' , 'ring' , 'placeholder' , 'divide' , 'accent' , 'caret' , 'fill' , 'stroke' , 'outline' , 'decoration' , 'shadow' , 'ring-offset' , 'from' , 'via' , 'to' ] . includes ( opacityMatch [ 1 ] ) ) {
13531440 return {
13541441 raw : className ,
13551442 variants,
0 commit comments