Skip to content

getCSS - first implementation#7

Open
ameerabuf wants to merge 74 commits intomasterfrom
get_css
Open

getCSS - first implementation#7
ameerabuf wants to merge 74 commits intomasterfrom
get_css

Conversation

@ameerabuf
Copy link
Copy Markdown
Contributor

@ameerabuf ameerabuf commented Dec 7, 2025

How the Generated CSS Works

Multiple interactions can target the same DOM element. CSS list properties like animation and transition are not additive — setting them again overwrites the previous value. We need a way to concatenate values from independent interactions while still allowing cascade override within a single interaction.

The solution: custom-property indirection

Each effect writes its animation/transition shorthand into a custom property (--animation-<interactionIdx>-<hash>) rather than directly into animation. A final coordinated-list rule on each target reads all custom properties via var() and joins them into the actual CSS property:

effect rule:       --animation-0-HASH: fadeIn 600ms …;
effect rule:       --animation-1-HASH: slideIn 400ms …;
coordinated list:  animation: var(--animation-0-HASH, none), var(--animation-1-HASH, none);

This pattern applies to animation, animation-composition, animation-timeline, animation-range, and transition.

Custom property naming scheme

Scenario Custom property pattern Who writes it
Normal effect in interaction i --animation-<i>-<hash> effect rule
Sequence step s in interaction i --animation-<i>-<s>-<hash> effect rule
Sequence inner list (→ feeds outer) --animation-<i>-<hash> = var(--animation-<i>-0-…), var(--animation-<i>-1-…) inner coordinated list
Outer coordinated list animation = var(--animation-0-…), var(--animation-1-…) outer coordinated list

The <hash> suffix is a deterministic encoding of the target element identifier (key + selector).


Examples

Each example uses simple inline keyframeEffect configs (no registered presets). Annotations use markers to highlight the key lines.


Example 1 — Single animation (base case)

One interaction, one animation effect. Shows the custom property + coordinated list in their simplest form.

Config:

const config: InteractConfig = {
  effects: {},
  interactions: [{
    key: 'box',
    trigger: 'click',
    effects: [{
      effectId: 'fadeIn',
      duration: 600,
      keyframeEffect: {
        name: 'fadeIn',
        keyframes: [{ opacity: '0' }, { opacity: '1' }],
      },
    }],
  }],
};

Generated CSS:

@keyframes fadeIn {
  0%  { opacity: 0; }
  100% { opacity: 1; }
}

/* Effect rule — writes into a custom property, NOT directly into `animation` */
[data-interact-key="box"] > :first-child {
  --animation-0-u29h2izz1i: fadeIn 600ms 1ms linear 1 paused;          /* ⬅ the value */
  --animation-composition-0-u29h2izz1i: replace;
  --animation-timeline-0-u29h2izz1i: auto;
  --animation-range-0-u29h2izz1i: normal;
}

/* Coordinated-list rule — reads the custom property into the real `animation` */
[data-interact-key="box"] > :first-child {
  animation: var(--animation-0-u29h2izz1i, none);                      /* ⬅ */
  animation-composition: var(--animation-composition-0-u29h2izz1i, replace);
  animation-timeline: var(--animation-timeline-0-u29h2izz1i, auto);
  animation-range: var(--animation-range-0-u29h2izz1i, normal);
}

With only one interaction, the indirection looks like overhead. Its value becomes clear in Examples 2–4.


Example 2 — Cross-interaction concatenation

Two interactions target the same element (box): a click animation and a hover transition. Each gets its own custom property (--animation-0-… and --transition-1-…). The coordinated list concatenates them — both animation and transition work simultaneously.

Config:

const config: InteractConfig = {
  effects: {},
  interactions: [
    {
      key: 'box',
      trigger: 'click',
      effects: [{
        effectId: 'fadeIn', duration: 600,
        keyframeEffect: {
          name: 'fadeIn',
          keyframes: [{ opacity: '0' }, { opacity: '1' }],
        },
      }],
    },
    {
      key: 'box',
      trigger: 'hover',
      effects: [{
        effectId: 'grow',
        transition: {
          styleProperties: [{ name: 'transform', value: 'scale(1.1)' }],
          duration: 300,
        },
      }],
    },
  ],
};

Generated CSS:

@keyframes fadeIn {
  0%  { opacity: 0; }
  100% { opacity: 1; }
}

/* Interaction 0 (click) — animation custom prop */
[data-interact-key="box"] > :first-child {
  --animation-0-u29h2izz1i: fadeIn 600ms 1ms linear 1 paused;          /* ⬅ interaction 0 */
  --animation-composition-0-u29h2izz1i: replace;
  --animation-timeline-0-u29h2izz1i: auto;
  --animation-range-0-u29h2izz1i: normal;
}

/* Interaction 1 (hover) — transition custom prop */
[data-interact-key="box"] > :first-child {
  --transition-1-u29h2izz1i: transform 300ms ease;                     /* ⬅ interaction 1 */
}

/* Transition state rule — applied when the effect is active */
[data-interact-key="box"]:is(:state(grow), :--grow, [data-interact-effect~="grow"]) > :first-child {
  transform: scale(1.1);
}

/* Coordinated-list rule — joins BOTH interactions' custom properties */
[data-interact-key="box"] > :first-child {
  animation: var(--animation-0-u29h2izz1i, none);                      /* ⬅ from interaction 0 */
  animation-composition: var(--animation-composition-0-u29h2izz1i, replace);
  animation-timeline: var(--animation-timeline-0-u29h2izz1i, auto);
  animation-range: var(--animation-range-0-u29h2izz1i, normal);
  transition: var(--transition-1-u29h2izz1i, _);                       /* ⬅ from interaction 1 */
}

The animation and transition live in separate custom properties (-0- vs -1-), so they coexist without overwriting each other.


Example 3 — Same-interaction cascade (override)

One interaction with two effects on the same target. Both write to the same custom property (--animation-0-…). CSS cascade means the later rule wins — this is intentional: effects in the same interaction array are alternatives, and the last applicable one takes effect.

Config:

const config: InteractConfig = {
  effects: {},
  interactions: [{
    key: 'box',
    trigger: 'click',
    effects: [
      {
        effectId: 'fadeIn', duration: 600,
        keyframeEffect: {
          name: 'fadeIn',
          keyframes: [{ opacity: '0' }, { opacity: '1' }],
        },
      },
      {
        effectId: 'slideIn', duration: 400,
        keyframeEffect: {
          name: 'slideIn',
          keyframes: [{ transform: 'translateY(20px)' }, { transform: 'translateY(0)' }],
        },
      },
    ],
  }],
};

Generated CSS:

@keyframes fadeIn {
  0%  { opacity: 0; }
  100% { opacity: 1; }
}
@keyframes slideIn {
  0%  { transform: translateY(20px); }
  100% { transform: translateY(0); }
}

/* Effect 1 — writes to --animation-0-… */
[data-interact-key="box"] > :first-child {
  --animation-0-u29h2izz1i: fadeIn 600ms 1ms linear 1 paused;          /* ⬅ same prop name */
  --animation-composition-0-u29h2izz1i: replace;
  --animation-timeline-0-u29h2izz1i: auto;
  --animation-range-0-u29h2izz1i: normal;
}

/* Effect 2 — also writes to --animation-0-… → cascade override! */
[data-interact-key="box"] > :first-child {
  --animation-0-u29h2izz1i: slideIn 400ms 1ms linear 1 paused;         /* ⬅ same prop name → wins */
  --animation-composition-0-u29h2izz1i: replace;
  --animation-timeline-0-u29h2izz1i: auto;
  --animation-range-0-u29h2izz1i: normal;
}

/* Coordinated-list rule — only one var() since both wrote to the same prop */
[data-interact-key="box"] > :first-child {
  animation: var(--animation-0-u29h2izz1i, none);                      /* ⬅ resolves to slideIn */
  animation-composition: var(--animation-composition-0-u29h2izz1i, replace);
  animation-timeline: var(--animation-timeline-0-u29h2izz1i, auto);
  animation-range: var(--animation-range-0-u29h2izz1i, normal);
}

Both rules target the same selector with the same custom property. The second rule's value wins via normal CSS cascade. This is how conditions work: when the first effect has a condition and the second doesn't, the conditional one is overridden by the unconditional fallback — unless the condition matches, in which case specificity or order determines the winner.


Example 4 — Sequence (inner + outer coordinated lists)

A sequence of two effects inside one interaction. Each effect gets a unique custom property with a secondary index (--animation-0-0-…, --animation-0-1-…). An inner coordinated list concatenates them into the interaction-level custom property (--animation-0-…), which then feeds into the outer coordinated list.

Config:

const config: InteractConfig = {
  effects: {},
  interactions: [{
    key: 'box',
    trigger: 'click',
    sequences: [{
      effects: [
        {
          effectId: 'fadeIn', duration: 600,
          keyframeEffect: {
            name: 'fadeIn',
            keyframes: [{ opacity: '0' }, { opacity: '1' }],
          },
        },
        {
          effectId: 'slideIn', duration: 400,
          keyframeEffect: {
            name: 'slideIn',
            keyframes: [{ transform: 'translateY(20px)' }, { transform: 'translateY(0)' }],
          },
        },
      ],
    }],
  }],
};

Generated CSS:

@keyframes fadeIn {
  0%  { opacity: 0; }
  100% { opacity: 1; }
}
@keyframes slideIn {
  0%  { transform: translateY(20px); }
  100% { transform: translateY(0); }
}

/* Sequence step 0 — note the double index: 0-0 (interaction 0, step 0) */
[data-interact-key="box"] > :first-child {
  --animation-0-0-u29h2izz1i: fadeIn 600ms 1ms linear 1 paused;        /* ⬅ step 0 */
  --animation-composition-0-0-u29h2izz1i: replace;
  --animation-timeline-0-0-u29h2izz1i: auto;
  --animation-range-0-0-u29h2izz1i: normal;
}

/* Sequence step 1 — index: 0-1 (interaction 0, step 1) */
[data-interact-key="box"] > :first-child {
  --animation-0-1-u29h2izz1i: slideIn 400ms 1ms linear 1 paused;       /* ⬅ step 1 */
  --animation-composition-0-1-u29h2izz1i: replace;
  --animation-timeline-0-1-u29h2izz1i: auto;
  --animation-range-0-1-u29h2izz1i: normal;
}

/* Inner coordinated list — joins step props into the interaction-level prop */
[data-interact-key="box"] > :first-child {
  --animation-0-u29h2izz1i:                                            /* ⬅ interaction-level */
    var(--animation-0-0-u29h2izz1i, none),                             /*    step 0 */
    var(--animation-0-1-u29h2izz1i, none);                             /*    step 1 */
  --animation-composition-0-u29h2izz1i:
    var(--animation-composition-0-0-u29h2izz1i, replace),
    var(--animation-composition-0-1-u29h2izz1i, replace);
  --animation-timeline-0-u29h2izz1i:
    var(--animation-timeline-0-0-u29h2izz1i, auto),
    var(--animation-timeline-0-1-u29h2izz1i, auto);
  --animation-range-0-u29h2izz1i:
    var(--animation-range-0-0-u29h2izz1i, normal),
    var(--animation-range-0-1-u29h2izz1i, normal);
}

/* Outer coordinated list — reads the interaction-level prop into `animation` */
[data-interact-key="box"] > :first-child {
  animation: var(--animation-0-u29h2izz1i, none);                      /* ⬅ */
  animation-composition: var(--animation-composition-0-u29h2izz1i, replace);
  animation-timeline: var(--animation-timeline-0-u29h2izz1i, auto);
  animation-range: var(--animation-range-0-u29h2izz1i, normal);
}

Two levels of indirection: step props (-0-0-, -0-1-) → inner list (-0-) → outer list (animation). At runtime, JavaScript controls each step individually by toggling its custom property, enabling sequential playback without rewriting the entire animation value.

Comment thread packages/interact/src/core/getCSS.ts Outdated
Comment thread packages/interact/src/core/getCSS.ts Outdated
Comment thread packages/interact/src/core/getCSS.ts Outdated
Comment thread packages/interact/src/core/getCSS.ts Outdated
Comment thread packages/interact/src/utils.ts
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/utils.ts Outdated
Comment thread packages/interact/src/core/utilities.ts Outdated
Comment thread packages/interact/src/core/utilities.ts Outdated
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/core/css.ts Outdated
@ameerabuf ameerabuf requested a review from ydaniv January 11, 2026 22:32
Comment thread apps/demo/src/react/components/CSSGenerationDemo.tsx Outdated
Comment thread apps/demo/src/react/components/CSSGenerationDemo.tsx Outdated
Comment thread apps/demo/src/react/components/CSSGenerationDemo.tsx Outdated
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/core/css.ts Outdated
Copy link
Copy Markdown
Collaborator

@ydaniv ydaniv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First iteration, didn't cover all yet.

Comment thread packages/interact/src/utils.ts Outdated
Comment thread packages/interact/src/utils.ts Outdated
Comment thread packages/interact/src/types/effects.ts
Comment thread packages/interact/src/types/css.ts Outdated
Comment thread packages/interact/src/types/css.ts Outdated
Comment thread packages/interact/src/core/css.ts
Comment thread packages/interact/src/core/css.ts Outdated
Comment thread packages/interact/src/core/resolvers.ts Outdated
Comment thread packages/interact/src/core/resolvers.ts
Comment thread packages/interact/src/core/resolvers.ts Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants