diff --git a/src/__tests__/native/transition-variables.test.tsx b/src/__tests__/native/transition-variables.test.tsx
new file mode 100644
index 00000000..2971441e
--- /dev/null
+++ b/src/__tests__/native/transition-variables.test.tsx
@@ -0,0 +1,83 @@
+import { StyleSheet } from "react-native";
+
+import { render, screen } from "@testing-library/react-native";
+import { View } from "react-native-css/components/View";
+import { registerCSS, testID } from "react-native-css/jest";
+
+// Observe the engine's transition contract before Reanimated consumes it.
+jest.mock("../../native/reanimated", () => ({
+ animatedComponentFamily: (component: unknown) => component,
+}));
+
+// A second definition keeps each variable from being inlined, so it resolves at runtime
+const decoy = `.decoy { --transition: opacity 9s; --transition-property: opacity; }`;
+
+const renderStyle = (className: string) => {
+ render();
+ return StyleSheet.flatten(screen.getByTestId(testID).props.style) as
+ | Record
+ | undefined;
+};
+
+test.each([
+ "width 1s ease-in 200ms",
+ "width 1s, opacity 2s linear",
+ "width, opacity",
+ "width 1s cubic-bezier(0.25, 0.5, 0.75, 1)",
+ "background-image 1s",
+ "all 300ms ease-out 50ms",
+ "1s",
+])(
+ "a transition held in a variable expands like the written one: %s",
+ (value) => {
+ registerCSS(`
+ ${decoy}
+ .written { transition: ${value}; }
+ .held { --transition: ${value}; transition: var(--transition); }
+ `);
+
+ const written = renderStyle("written");
+
+ expect(written).toHaveProperty("transitionDuration");
+ expect(renderStyle("held")).toStrictEqual(written);
+ },
+);
+
+test("a transition names the style key each property is written to", () => {
+ registerCSS(
+ `.written { transition-property: width, background-image, border-inline-start-color; }`,
+ );
+
+ expect(renderStyle("written")).toStrictEqual({
+ transitionProperty: [
+ "width",
+ "experimental_backgroundImage",
+ "borderStartColor",
+ ],
+ });
+});
+
+test("a transition-property held in a variable names the keys the written one names", () => {
+ registerCSS(`
+ ${decoy}
+ .written { transition-property: width, background-image, border-inline-start-color; }
+ .held { --transition-property: width, background-image, border-inline-start-color; transition-property: var(--transition-property); }
+ `);
+
+ expect(renderStyle("held")).toStrictEqual(renderStyle("written"));
+});
+
+test.each([
+ ["three times", "width 1s 2s 3s"],
+ ["two easing functions", "width 1s ease-in linear"],
+])(
+ "a transition held in a variable with %s is invalid and writes no transition",
+ (_case, value) => {
+ registerCSS(`
+ ${decoy}
+ .held { --transition: ${value}; transition: var(--transition); }
+ `);
+
+ expect(renderStyle("held")).toStrictEqual({});
+ },
+);
diff --git a/src/__tests__/utilities/property-names.test.ts b/src/__tests__/utilities/property-names.test.ts
new file mode 100644
index 00000000..f64fb5e4
--- /dev/null
+++ b/src/__tests__/utilities/property-names.test.ts
@@ -0,0 +1,29 @@
+import {
+ propertyRename,
+ toRNProperty,
+ transitionStyleKey,
+} from "react-native-css/utilities";
+
+test("the rename census is not empty", () => {
+ expect(Object.keys(propertyRename).length).toBeGreaterThan(0);
+});
+
+test.each(Object.entries(propertyRename))(
+ "a transition on %s names the key it is written to",
+ (property, renamed) => {
+ expect(transitionStyleKey(property)).toBe(toRNProperty(renamed));
+ },
+);
+
+test.each([
+ ["width", "width"],
+ ["background-color", "backgroundColor"],
+ ["all", "all"],
+ ["none", "none"],
+])("a transition on %s names %s", (property, key) => {
+ expect(transitionStyleKey(property)).toBe(key);
+});
+
+test("visibility names no key to animate", () => {
+ expect(transitionStyleKey("visibility")).toBeUndefined();
+});
diff --git a/src/compiler/atRules.ts b/src/compiler/atRules.ts
index 6943f672..730aef41 100644
--- a/src/compiler/atRules.ts
+++ b/src/compiler/atRules.ts
@@ -6,8 +6,8 @@ import type {
TokenOrValue,
} from "lightningcss";
+import { toRNProperty } from "../utilities";
import type { StyleRuleMapping } from "./compiler.types";
-import { toRNProperty } from "./selectors";
import { splitByDelimiter } from "./split-by-delimiter";
import type { StylesheetBuilder } from "./stylesheet";
diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts
index 9ca2aab8..f4787fa4 100644
--- a/src/compiler/declarations.ts
+++ b/src/compiler/declarations.ts
@@ -36,7 +36,12 @@ import type {
UnresolvedColor,
} from "lightningcss";
-import { isStyleFunction } from "../utilities";
+import {
+ isStyleFunction,
+ propertyRename,
+ toRNProperty,
+ transitionStyleKey,
+} from "../utilities";
import { isMappingProperty } from "./atRules";
import type {
StyleDescriptor,
@@ -44,7 +49,6 @@ import type {
StyleRule,
} from "./compiler.types";
import { parseEasingFunction, parseIterationCount } from "./keyframes";
-import { toRNProperty } from "./selectors";
import type { StylesheetBuilder } from "./stylesheet";
const CommaSeparator = Symbol("CommaSeparator");
@@ -61,19 +65,6 @@ type Parser = (
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
) => StyleDescriptor | void;
-const propertyRename: Record = {
- "background-image": "experimental_backgroundImage",
- // React Native has no border-inline-* props, but ships the equivalent
- // RTL-aware border-start-* / border-end-* props
- "border-inline-end-color": "border-end-color",
- "border-inline-end-width": "border-end-width",
- "border-inline-start-color": "border-start-color",
- "border-inline-start-width": "border-start-width",
- "border-block-start-width": "border-top-width",
- "border-block-end-width": "border-bottom-width",
- "font-variant-caps": "font-variant",
-};
-
// React Native only supports a uniform borderStyle, so per-side border
// styles have no native equivalent and are dropped. "solid" is dropped
// silently as it matches React Native's default rendering.
@@ -99,6 +90,8 @@ const unparsedRuntimeParsing = new Set([
"scale",
"text-shadow",
"transform",
+ "transition",
+ "transition-property",
"translate",
]);
@@ -3060,13 +3053,8 @@ export function addTransitionValue(
declaration.property,
declaration.value
.map((v) => v.property)
- .filter(
- (v) =>
- (v in parsers && !["visibility"].includes(v)) ||
- v === "all" ||
- v === "none",
- )
- .map((v) => toRNProperty(v)),
+ .filter((v) => v in parsers || v === "all" || v === "none")
+ .flatMap((v) => transitionStyleKey(v) ?? []),
);
return;
}
diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts
index 6787c0ca..f2a2cd75 100644
--- a/src/compiler/selector-builder.ts
+++ b/src/compiler/selector-builder.ts
@@ -1,6 +1,6 @@
import type { AttrOperation, Selector, SelectorList } from "lightningcss";
-import { Specificity } from "../utilities";
+import { Specificity, toRNProperty } from "../utilities";
import type {
AttributeQuery,
AttrSelectorOperator,
@@ -597,17 +597,6 @@ function isUniversalSelector([first, second]: Selector) {
return first && first.type === "universal" && !second;
}
-export function toRNProperty(str: T) {
- return str
- .replace(/^-rn-/, "")
- .replace(/-./g, (x) => x[1]?.toUpperCase() ?? "") as CamelCase;
-}
-
-type CamelCase =
- S extends `${infer P1}-${infer P2}${infer P3}`
- ? `${Lowercase}${Uppercase}${CamelCase}`
- : Lowercase;
-
const operatorMap: Record = {
"equal": "=",
"includes": "~=",
diff --git a/src/compiler/selectors.ts b/src/compiler/selectors.ts
deleted file mode 100644
index b8623072..00000000
--- a/src/compiler/selectors.ts
+++ /dev/null
@@ -1,505 +0,0 @@
-/* eslint-disable */
-import type { Selector, SelectorList } from "lightningcss";
-
-import { Specificity } from "../utilities";
-import type {
- AttributeQuery,
- AttrSelectorOperator,
- CompilerOptions,
- ContainerQuery,
- MediaCondition,
- PseudoClassesQuery,
- SpecificityArray,
-} from "./compiler.types";
-import { StylesheetBuilder } from "./stylesheet";
-
-export type NormalizeSelector =
- | ClassNameSelector
- | {
- type: "rootVariables" | "universalVariables";
- subtype: "light" | "dark";
- };
-
-type ClassNameSelector = {
- type: "className";
- specificity: SpecificityArray;
- className: string;
- mediaQuery?: MediaCondition[];
- containerQuery?: ContainerQuery[];
- pseudoClassesQuery?: PseudoClassesQuery;
- attributeQuery?: AttributeQuery[];
-};
-
-/**
- * Turns a CSS selector into a `react-native-css` selector.
- */
-export function getSelectors(
- selectorList: SelectorList,
- isDarkMode: boolean,
- builder: StylesheetBuilder,
- selectors: NormalizeSelector[] = [],
-) {
- for (let cssSelector of selectorList) {
- // Ignore `:is()` & `:where()`, and just process its selectors
- if (isIsPseudoClass(cssSelector) || isWherePseudoClass(cssSelector)) {
- getSelectors(cssSelector[0].selectors, isDarkMode, builder, selectors);
- } else if (
- // Matches: :root {}
- isRootVariableSelector(cssSelector)
- ) {
- // Matches: @media(prefers-dark-mode) { :root {} }
- if (isDarkMode) {
- selectors.push({
- type: "rootVariables",
- subtype: "dark",
- });
- } else {
- selectors.push({
- type: "rootVariables",
- subtype: "light",
- });
- }
- } else if (
- // Matches: .dark:root {} || :root[class~="dark"]
- isRootDarkVariableSelector(cssSelector)
- ) {
- selectors.push({
- type: "rootVariables",
- subtype: "dark",
- });
- } else if (
- // Matches @media(prefers-dark-mode) { * {} }
- isDarkMode &&
- isDefaultVariableSelector(cssSelector)
- ) {
- selectors.push({
- type: "universalVariables",
- subtype: "dark",
- });
- } else if (
- // Matches: * {}
- isDefaultVariableSelector(cssSelector)
- ) {
- selectors.push({
- type: "universalVariables",
- subtype: "light",
- });
- // } else if (
- // // Matches: .dark * {}
- // isDarkUniversalSelector(cssSelector)
- // ) {
- // selectors.push({
- // type: "universalVariables",
- // subtype: "dark",
- // });
- // } else if (
- // // Matches: .dark {}
- // isDarkClassLegacySelector(cssSelector, collection)
- // ) {
- // const [_, __, third, ...rest] = cssSelector;
-
- // getSelectors(extractedStyle, [[third!, ...rest]], collection, selectors, [
- // ["=", "prefers-color-scheme", "dark"],
- // ]);
- // } else if (
- // // Matches: :is(.dark *) {}
- // isDarkClassSelector(cssSelector, collection)
- // ) {
- // const [first] = cssSelector;
-
- // getSelectors(extractedStyle, [[first!]], collection, selectors, [
- // ["=", "prefers-color-scheme", "dark"],
- // ]);
- } else {
- const selector = classNameSelector(cssSelector, builder.getOptions());
-
- if (selector === null) {
- continue;
- }
-
- selectors.push(selector);
- }
- }
-
- return selectors;
-}
-
-function classNameSelector(
- selector: Selector,
- options: CompilerOptions,
-): ClassNameSelector | null {
- let primaryClassName: string | undefined;
- const specificity: SpecificityArray = [];
- let mediaQuery: MediaCondition[] | undefined;
- let containerQuery: ContainerQuery[] | undefined;
- let attributeQuery: AttributeQuery[] | undefined;
- let pseudoClassesQuery: PseudoClassesQuery | undefined;
-
- let currentContainerQuery: ContainerQuery | undefined;
-
- let isInClassBlock = false;
- let newBlock = false;
-
- function getAttributeQuery() {
- if (newBlock) {
- currentContainerQuery ??= {};
- }
-
- if (currentContainerQuery) {
- if (!currentContainerQuery.a) {
- currentContainerQuery.a = [];
- }
- return currentContainerQuery.a;
- } else {
- attributeQuery ??= [];
- return attributeQuery;
- }
- }
-
- function getPseudoClassesQuery() {
- if (newBlock) {
- currentContainerQuery ??= {};
- }
-
- if (currentContainerQuery) {
- if (!currentContainerQuery.p) {
- currentContainerQuery.p = {};
- }
- return currentContainerQuery.p;
- } else {
- pseudoClassesQuery ??= {};
- return pseudoClassesQuery;
- }
- }
-
- /*
- * Loop over each token in reverse order.
- */
- for (const component of selector.reverse()) {
- switch (component.type) {
- case "universal":
- case "namespace":
- case "id":
- case "pseudo-element":
- // We don't support these selectors at all
- return null;
- case "nesting":
- continue;
- case "class": {
- if (!primaryClassName) {
- primaryClassName = component.name;
- } else if (isInClassBlock) {
- getAttributeQuery().unshift(["a", "className", "*=", component.name]);
- } else if (component.name !== options.selectorPrefix) {
- if (currentContainerQuery?.n) {
- getAttributeQuery().unshift([
- "a",
- "className",
- "*=",
- component.name,
- ]);
- } else {
- currentContainerQuery ??= {};
- currentContainerQuery.n = component.name;
-
- containerQuery ??= [];
- containerQuery.unshift(currentContainerQuery);
- }
- }
-
- isInClassBlock = true;
- newBlock = false;
-
- specificity[Specificity.ClassName] =
- (specificity[Specificity.ClassName] ?? 0) + 1;
-
- break;
- }
- case "pseudo-class": {
- specificity[Specificity.ClassName] =
- (specificity[Specificity.ClassName] ?? 0) + 1;
-
- // if (component.kind === "is") {
- // if (isDarkUniversalSelector(component.selectors[0]!, collection)) {
- // const lastGroup = acc.classNames[acc.classNames.length - 1]!;
- // lastGroup[1] ??= {};
- // lastGroup[1].m ??= [];
- // lastGroup[1].m.push(["=", "prefers-color-scheme", "dark"]);
- // break;
- // }
- // }
-
- switch (component.kind) {
- case "hover": {
- getPseudoClassesQuery().h = 1;
- break;
- }
- case "active": {
- getPseudoClassesQuery().a = 1;
- break;
- }
- case "focus": {
- getPseudoClassesQuery().f = 1;
- break;
- }
- case "disabled": {
- getAttributeQuery().push(["a", "disabled"]);
- break;
- }
- case "empty": {
- getAttributeQuery().push(["a", "children", "!"]);
- break;
- }
- default: {
- // We don't support other pseudo-classes
- return null;
- }
- }
- break;
- }
- case "attribute": {
- // We don't support attribute selectors as standalone selectors
- // Except for a top level [dir] selector
- // if (classNames.length === 0) {
- // if (
- // component.name === "dir" &&
- // component.operation?.operator === "equal"
- // ) {
- // mediaQuery ??= [];
- // mediaQuery.push(["!!", component.operation.value]);
- // break;
- // }
-
- // // Ignore any other attributes that are not part of a class selector
- // return null;
- // }
-
- // Turn attribute selectors into AttributeConditions
- specificity[Specificity.ClassName] =
- (specificity[Specificity.ClassName] ?? 0) + 1;
-
- // [data-*] are turned into `dataSet` queries
- // Everything else is turned into `attribute` queries
- const attributeQuery: AttributeQuery = component.name.startsWith(
- "data-",
- )
- ? ["d", toRNProperty(component.name.replace("data-", ""))]
- : ["a", toRNProperty(component.name)];
-
- if (component.operation) {
- let operator: AttrSelectorOperator | undefined;
-
- switch (component.operation.operator) {
- case "equal":
- operator = "=";
- break;
- case "includes":
- operator = "~=";
- break;
- case "dash-match":
- operator = "|=";
- break;
- case "prefix":
- operator = "^=";
- break;
- case "substring":
- operator = "*=";
- break;
- case "suffix":
- operator = "$=";
- break;
- default:
- component.operation.operator satisfies never;
- break;
- }
-
- if (operator) {
- attributeQuery.push(operator, component.operation.value);
- }
- }
-
- getAttributeQuery().push(attributeQuery);
- break;
- }
- case "type": {
- /*
- * We only support type selectors as part of the selector prefix
- * For example: `html .my-class`
- *
- * NOTE: We ignore specificity for this
- */
- if (component.name === options.selectorPrefix) {
- break;
- }
- return null;
- }
- case "combinator": {
- // We only support the descendant combinator
- if (component.value === "descendant") {
- isInClassBlock = false;
- newBlock = true;
- currentContainerQuery = undefined;
- break;
- }
-
- return null;
- }
- }
- }
-
- if (!primaryClassName) {
- // No class name found, return null
- return null;
- }
-
- return {
- type: "className",
- specificity: specificity,
- className: primaryClassName,
- mediaQuery,
- containerQuery,
- pseudoClassesQuery,
- attributeQuery,
- };
-}
-
-function isIsPseudoClass(
- selector: Selector,
-): selector is [{ type: "pseudo-class"; kind: "is"; selectors: Selector[] }] {
- return (
- selector.length === 1 &&
- selector[0]?.type === "pseudo-class" &&
- selector[0].kind === "is"
- );
-}
-
-function isWherePseudoClass(
- selector: Selector,
-): selector is [
- { type: "pseudo-class"; kind: "where"; selectors: Selector[] },
-] {
- return (
- selector.length === 1 &&
- selector[0]?.type === "pseudo-class" &&
- selector[0].kind === "where"
- );
-}
-
-// function isDarkModeMediaQuery(query?: MediaCondition): boolean {
-// if (!query) return false;
-
-// return (
-// query[0] === "=" &&
-// query[1] === "prefers-color-scheme" &&
-// query[2] === "dark"
-// );
-// }
-
-// Matches: :is(.dark *)
-// function isDarkClassSelector(
-// [first, second, third]: Selector,
-// collection: CompilerCollection,
-// ) {
-// if (!collection.darkMode) {
-// return false;
-// }
-
-// return (
-// first &&
-// second &&
-// !third &&
-// first.type === "class" &&
-// second.type === "pseudo-class" &&
-// second.kind === "is" &&
-// second.selectors.length === 1 &&
-// second.selectors[0]?.length === 3 &&
-// second.selectors[0][0]?.type === "class" &&
-// second.selectors[0][0].name === collection.darkMode &&
-// second.selectors[0][1]?.type === "combinator" &&
-// second.selectors[0][1].value === "descendant" &&
-// second.selectors[0][2]?.type === "universal"
-// );
-// }
-
-// Matches: .dark {}
-// function isDarkClassLegacySelector(
-// [first, second, third]: Selector,
-// collection: CompilerCollection,
-// ) {
-// if (!collection.darkMode) {
-// return false;
-// }
-
-// return (
-// first &&
-// second &&
-// third &&
-// first.type === "class" &&
-// first.name === collection.darkMode &&
-// second.type === "combinator" &&
-// second.value === "descendant" &&
-// third.type === "class"
-// );
-// }
-
-// Matches: :root {}
-function isRootVariableSelector([first, second]: Selector) {
- return (
- first && !second && first.type === "pseudo-class" && first.kind === "root"
- );
-}
-
-// Matches: * {}
-function isDefaultVariableSelector([first, second]: Selector) {
- return first && !second && first.type === "universal";
-}
-
-// Matches: .dark:root {}
-function isRootDarkVariableSelector([first, second]: Selector) {
- return (
- first &&
- second &&
- // .dark:root {}
- ((first.type === "class" &&
- second.type === "pseudo-class" &&
- second.kind === "root") ||
- // :root[class~=dark] {}
- (first.type === "pseudo-class" &&
- first.kind === "root" &&
- second.type === "attribute" &&
- second.name === "class" &&
- second.operation &&
- ["includes", "equal"].includes(second.operation.operator)))
- );
-}
-
-// Matches: .dark * {}
-// function isDarkUniversalSelector(
-// [first, second, third]: Selector,
-// collection: CompilerCollection,
-// ) {
-// if (!collection.darkMode) {
-// return false;
-// }
-// return (
-// first &&
-// second &&
-// third &&
-// first.type === "class" &&
-// first.name === collection.darkMode &&
-// second.type === "combinator" &&
-// second.value === "descendant" &&
-// third.type === "universal"
-// );
-// }
-
-export function toRNProperty(str: T) {
- return str
- .replace(/^-rn-/, "")
- .replace(/-./g, (x) => x[1]!.toUpperCase()) as CamelCase;
-}
-
-type CamelCase =
- S extends `${infer P1}-${infer P2}${infer P3}`
- ? `${Lowercase}${Uppercase}${CamelCase}`
- : Lowercase;
diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts
index a77fdf89..ffb4c581 100644
--- a/src/compiler/stylesheet.ts
+++ b/src/compiler/stylesheet.ts
@@ -4,6 +4,7 @@ import {
isStyleDescriptorArray,
Specificity,
specificityCompareFn,
+ toRNProperty,
} from "../utilities";
import type {
AnimationKeyframes,
@@ -25,7 +26,7 @@ import {
modifyRuleForPlaceholder,
modifyRuleForSelection,
} from "./pseudo-elements";
-import { getClassNameSelectors, toRNProperty } from "./selector-builder";
+import { getClassNameSelectors } from "./selector-builder";
type BuilderMode = "style" | "media" | "container" | "keyframes";
diff --git a/src/native/styles/shorthands/index.ts b/src/native/styles/shorthands/index.ts
index f9ed4196..0e6202bf 100644
--- a/src/native/styles/shorthands/index.ts
+++ b/src/native/styles/shorthands/index.ts
@@ -4,3 +4,4 @@ export * from "./logical-border-width";
export * from "./box-shadow";
export * from "./text-shadow";
export * from "./transform";
+export * from "./transition";
diff --git a/src/native/styles/shorthands/transition.ts b/src/native/styles/shorthands/transition.ts
new file mode 100644
index 00000000..c804f1ce
--- /dev/null
+++ b/src/native/styles/shorthands/transition.ts
@@ -0,0 +1,133 @@
+import { transitionStyleKey } from "react-native-css/utilities";
+
+import { ShortHandSymbol } from "../constants";
+import type { StyleFunctionResolver } from "../resolve";
+
+const EASING_KEYWORDS: ReadonlySet = new Set([
+ "linear",
+ "ease",
+ "ease-in",
+ "ease-out",
+ "ease-in-out",
+ "step-start",
+ "step-end",
+]);
+
+/** One `` (CSS Transitions 1 ยง2.5), whose components come in any order. */
+interface SingleTransition {
+ readonly property: string;
+ readonly duration: number;
+ readonly delay: number;
+ readonly timingFunction: unknown;
+}
+
+function isPropertyName(token: unknown): token is string {
+ return typeof token === "string" && !EASING_KEYWORDS.has(token);
+}
+
+function isTimingFunction(token: unknown): boolean {
+ return (
+ (typeof token === "string" && EASING_KEYWORDS.has(token)) ||
+ (typeof token === "object" && token !== null && !Array.isArray(token))
+ );
+}
+
+/** The first time is the duration and the second the delay; anything else makes the transition invalid. */
+function readSingleTransition(
+ tokens: readonly unknown[],
+): SingleTransition | undefined {
+ const properties = tokens.filter(isPropertyName);
+ const times = tokens.filter((token) => typeof token === "number");
+ const timingFunctions = tokens.filter(isTimingFunction);
+
+ if (
+ properties.length > 1 ||
+ times.length > 2 ||
+ timingFunctions.length > 1 ||
+ properties.length + times.length + timingFunctions.length !== tokens.length
+ ) {
+ return undefined;
+ }
+
+ return {
+ property: properties[0] ?? "all",
+ duration: times[0] ?? 0,
+ delay: times[1] ?? 0,
+ timingFunction: timingFunctions[0] ?? "ease",
+ };
+}
+
+/**
+ * A variable's transition list as its comma groups. A group of one token arrives
+ * bare rather than as a list of one, so a list holding no list is one group
+ * unless a second property name opens the next.
+ */
+function readTransitionGroups(value: unknown): readonly (readonly unknown[])[] {
+ if (!Array.isArray(value)) {
+ return [[value]];
+ }
+
+ if (value.some((token) => Array.isArray(token))) {
+ return value.map((group): readonly unknown[] =>
+ Array.isArray(group) ? group : [group],
+ );
+ }
+
+ const groups: unknown[][] = [];
+ let group: unknown[] = [];
+
+ for (const token of value) {
+ if (isPropertyName(token) && group.some(isPropertyName)) {
+ groups.push(group);
+ group = [];
+ }
+ group.push(token);
+ }
+
+ groups.push(group);
+ return groups;
+}
+
+function isSingleTransition(
+ transition: SingleTransition | undefined,
+): transition is SingleTransition {
+ return transition !== undefined;
+}
+
+/** `transition` held in a variable, expanded into the longhands the compiler builds for a written one. */
+export const transition: StyleFunctionResolver = (resolveValue, func) => {
+ const parsed = readTransitionGroups(resolveValue(func[2])).map(
+ readSingleTransition,
+ );
+ const transitions = parsed.filter(isSingleTransition);
+
+ if (transitions.length === 0 || transitions.length !== parsed.length) {
+ return;
+ }
+
+ const timingFunctions = transitions.map((single) => single.timingFunction);
+
+ return {
+ [ShortHandSymbol]: true,
+ transitionProperty: transitions.flatMap(
+ (single) => transitionStyleKey(single.property) ?? [],
+ ),
+ transitionDuration: transitions.map((single) => single.duration),
+ transitionDelay: transitions.map((single) => single.delay),
+ transitionTimingFunction:
+ timingFunctions.length === 1 ? timingFunctions[0] : timingFunctions,
+ };
+};
+
+/** `transition-property` held in a variable, naming the style keys a written one names. */
+export const transitionProperty: StyleFunctionResolver = (
+ resolveValue,
+ func,
+) => {
+ const value = resolveValue(func[2]);
+ const names = (Array.isArray(value) ? value.flat() : [value]).filter(
+ (name): name is string => typeof name === "string",
+ );
+
+ return names.flatMap((name) => transitionStyleKey(name) ?? []);
+};
diff --git a/src/utilities/index.ts b/src/utilities/index.ts
index 0c95da63..4470e985 100644
--- a/src/utilities/index.ts
+++ b/src/utilities/index.ts
@@ -1,3 +1,4 @@
export * from "./specificity";
export * from "./style-descriptor";
export * from "./dot-notation.types";
+export * from "./property-names";
diff --git a/src/utilities/property-names.ts b/src/utilities/property-names.ts
new file mode 100644
index 00000000..1aed060a
--- /dev/null
+++ b/src/utilities/property-names.ts
@@ -0,0 +1,31 @@
+/** The React Native key a CSS property is written to, where it is not the property's own camelCase. */
+export const propertyRename: Record = {
+ "background-image": "experimental_backgroundImage",
+ // React Native has no border-inline-* props, but ships the equivalent
+ // RTL-aware border-start-* / border-end-* props
+ "border-inline-end-color": "border-end-color",
+ "border-inline-end-width": "border-end-width",
+ "border-inline-start-color": "border-start-color",
+ "border-inline-start-width": "border-start-width",
+ "border-block-start-width": "border-top-width",
+ "border-block-end-width": "border-bottom-width",
+ "font-variant-caps": "font-variant",
+};
+
+type CamelCase =
+ S extends `${infer P1}-${infer P2}${infer P3}`
+ ? `${Lowercase}${Uppercase}${CamelCase}`
+ : Lowercase;
+
+export function toRNProperty(str: T) {
+ return str
+ .replace(/^-rn-/, "")
+ .replace(/-./g, (x) => x[1]?.toUpperCase() ?? "") as CamelCase;
+}
+
+/** The style key a transition on `property` animates, which is the key the property is written to; `visibility` names none, as no style key carries it. */
+export function transitionStyleKey(property: string): string | undefined {
+ return property === "visibility"
+ ? undefined
+ : toRNProperty(propertyRename[property] ?? property);
+}