Skip to content

Block Supports: Add a Transform block support with a rotate feature - #86240

Draft
aaronrobertshaw wants to merge 19 commits into
fix/angle-picker-control-rangefrom
add/transform-block-support
Draft

aaronrobertshaw wants to merge 19 commits into
fix/angle-picker-control-rangefrom
add/transform-block-support

Conversation

@aaronrobertshaw

@aaronrobertshaw aaronrobertshaw commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Important

Stacked on #86631 (AnglePickerControl min, max and empty value). Merge #86631 first.

Related:

Core backport:

What?

Adds a Transform block support with rotation as its first feature. Group, Heading, Image and Video opt in.

Why?

Site owners and themes cannot rotate a block. A separate Transform support lets scale, skew or translate follow without schema or attribute changes.

How?

  • Value: style.transform.rotate holds one CSS angle. Anything else outputs nothing.
  • Output: the individual rotate property, never transform.
  • Target: the wrapper, or the transform feature selector. Image and Video rotate the whole block, caption included.
  • States: :hover and viewport rotations use the shared block states path. No opted-in block has a :hover state yet.
  • Global Styles: a Transform panel for blocks and variations, in both site editors. settings.transform.rotate turns it off.
  • UI: a Transform panel using AnglePickerControl. Rotation goes from -360 to 360. Clearing it removes the value, so an inherited rotation shows through again. It writes deg and shows other units in whole degrees.
  • Lightbox: the enlarged image stays level, and the zoom starts on the rotated image.

Testing Instructions

  1. Add an Image with a caption and lightbox. Set Styles > Transform > Rotation.
  2. Check the image and caption rotate together in the editor and front end. Open the lightbox; the zoom starts on the image and ends level.
  3. On a Heading, choose Mobile, turn on Responsive styles and set a rotation. Desktop stays level.
  4. Give Heading a theme.json rotation of "-0.025turn". Global Styles shows -9°, and the canvas matches the front end.
  5. Set Heading's Rotation to -45 on a block, then clear the field. The block goes back to the Global Styles -9°, in the editor and on the front end.

Screenshots or screencast

Image with a caption

composite

Rotation for Mobile only

2-heading-viewport.mp4

theme.json rotation in turns, in Global Styles

3-theme-json-turn.mp4

Use of AI Tools

Written with Claude Code, then reviewed and tested.

@aaronrobertshaw aaronrobertshaw added [Type] Enhancement A suggestion for improvement. [Feature] Block API API that allows to express the block paradigm. [Package] Blocks /packages/blocks [Package] Block library /packages/block-library [Package] Block editor /packages/block-editor [Feature] Design Tools Tools that impact the appearance of blocks both to expand the number of tools and improve the experi [Package] Style Engine /packages/style-engine labels Nov 3, 2026
@aaronrobertshaw aaronrobertshaw self-assigned this Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

🤖 PR meta 🤖

Preview in WordPress Playground Preview in Storybook

📦 Bundle size

Size Change: +1.2 kB (+0.01%)

Total Size: 8.64 MB

📦 View Changed
Filename Size Change
build/modules/block-library/image/view.min.js 3.49 kB +55 B (+1.6%)
build/modules/lazy-editor/index.min.js 14.6 kB +11 B (+0.08%)
build/scripts/block-editor/index.min.js 546 kB +709 B (+0.13%)
build/scripts/block-library/index.min.js 425 kB +31 B (+0.01%)
build/scripts/blocks/index.min.js 44.9 kB +18 B (+0.04%)
build/scripts/core-data/index.min.js 76.7 kB +15 B (+0.02%)
build/scripts/edit-site/index.min.js 379 kB +33 B (+0.01%)
build/scripts/editor/index.min.js 568 kB +96 B (+0.02%)
build/scripts/style-engine/index.min.js 2.35 kB +97 B (+4.3%)
build/styles/block-library/image/style-rtl.css 3.02 kB +20 B (+0.67%)
build/styles/block-library/image/style-rtl.min.css 1.97 kB +18 B (+0.92%)
build/styles/block-library/image/style.css 3.04 kB +21 B (+0.7%)
build/styles/block-library/image/style.min.css 1.96 kB +19 B (+0.98%)
build/styles/block-library/style-rtl.css 22.9 kB +15 B (+0.07%)
build/styles/block-library/style-rtl.min.css 19.4 kB +11 B (+0.06%)
build/styles/block-library/style.css 23 kB +15 B (+0.07%)
build/styles/block-library/style.min.css 19.4 kB +12 B (+0.06%)

acaae5c Run

⚡ Performance

Show the results

Client side metrics exclude the server response time.

front-end-block-theme

Metric 07b65bc trunk % Change
timeToFirstByte 44.3 ms +9.71% -5.08% 44.1 ms +8.05% -6.69% 0.45%
largestContentfulPaint 74 ms +10.81% -8.11% 68 ms +5.88% -2.94% 8.82%
lcpMinusTtfb 30.75 ms +4.07% -18.05% 23.85 ms +17.4% -5.87% 28.93%
wpBeforeTemplate 23.38 ms +10.22% -4.66% 22.48 ms +14.59% -2.85% 4%
wpTemplate 17.54 ms +6.1% -5.53% 17.1 ms +8.77% -4.27% 2.57%
wpTotal 41.03 ms +9.41% -5.24% 40.77 ms +8.02% -6.87% 0.64%
wpMemoryUsage 7.72 MB +0% -0% 7.68 MB +0% -0% 0.49%
wpDbQueries 16 +0% -0% 16 +0% -0% 0%

front-end-classic-theme

Metric 07b65bc trunk % Change
timeToFirstByte 35.8 ms +4.33% -3.21% 36.75 ms +7.48% -2.59% -2.59%
largestContentfulPaint 64 ms +3.13% -0% 68 ms +2.94% -5.88% -5.88%
lcpMinusTtfb 29.2 ms +2.4% -3.77% 28.65 ms +10.12% -2.27% 1.92%
wpBeforeTemplate 19.54 ms +3.12% -2.51% 20.07 ms +11.86% -1.99% -2.64%
wpTemplate 12.96 ms +7.02% -3.01% 13.16 ms +7.75% -2.43% -1.52%
wpTotal 32.97 ms +5.07% -3.82% 33.73 ms +8.57% -2.91% -2.25%
wpMemoryUsage 6.41 MB +0% -0% 6.37 MB +0% -0% 0.61%
wpDbQueries 13 +0% -0% 13 +0% -0% 0%

media-processing

Metric 07b65bc trunk % Change
mediaProcessingJpeg 326.06 ms +0.43% -0.31% 326.82 ms +1.32% -0.94% -0.23%
mediaProcessingAvif 4870.08 ms +0.58% -0.79% 4854.5 ms +0.17% -0.37% 0.32%
mediaProcessingJpegToAvif 3393.59 ms +0.07% -0.31% 3387.49 ms +0.38% -0.23% 0.18%

media-upload

Metric 07b65bc trunk % Change
jpegUploadProcessing 1468.47 ms +1.03% -0.6% 1452.1 ms +0.49% -0.46% 1.13%
pngUploadProcessing 250.31 ms +2.58% -3.4% 243.11 ms +4.73% -5.17% 2.96%
largeJpegUploadProcessing 1444.32 ms +1.27% -0.4% 1440.94 ms +0.62% -0.52% 0.23%
multipleImageUploadProcessing 1520.54 ms +0.6% -1.14% 1522.39 ms +0.43% -1.89% -0.12%

post-editor

Metric 07b65bc trunk % Change
serverResponse 324.18 ms +0.98% -9.78% 307.12 ms +6.83% -5.46% 5.55%
firstPaint 347.39 ms +6.19% -6.47% 324.24 ms +4.11% -4.28% 7.14%
domContentLoaded 626.15 ms +4.05% -0.67% 666.26 ms +0.74% -2.27% -6.02%
loaded 626.96 ms +4.03% -0.69% 667.2 ms +0.73% -2.29% -6.03%
firstContentfulPaint 362.91 ms +6.25% -3.84% 383.08 ms +0.67% -4.24% -5.27%
firstBlock 1663.7 ms +2.72% -0.74% 1751.31 ms +2.44% -1.86% -5%
type 14.38 ms +2.23% -10.78% 16.61 ms +4.21% -15.65% -13.43%
typeWithoutInspector 14.11 ms +4.82% -4.11% 17.15 ms +10.85% -13.47% -17.73%
typeWithTopToolbar 17.21 ms +10.23% -3.25% 20.63 ms +11.88% -5.48% -16.58%
typeContainer 6.54 ms +11.77% -5.81% 7.51 ms +3.6% -5.86% -12.92%
focus 90.14 ms +8.85% -0.72% 94.27 ms +3.15% -3.41% -4.38%
firstFocus 199.13 ms +0% -0% 248.37 ms +0% -0% -19.83%
selectAll 319.07 ms +1.61% -3.02% 314.8 ms +1.2% -0.72% 1.36%
undo 24.27 ms +2.84% -8.78% 26.6 ms +2.59% -9.02% -8.76%
listViewOpen 59.9 ms +8.43% -4.86% 60.22 ms +2.67% -2.11% -0.53%
inserterOpen 33.77 ms +11.58% -11.05% 33.98 ms +8.3% -4.27% -0.62%
inserterHover 9.12 ms +4.93% -7.13% 11.29 ms +17.09% -8.33% -19.22%
inserterSearch 6.8 ms +10.88% -5.88% 7.42 ms +7.14% -7.01% -8.36%
loadPatterns 511.15 ms +2.43% -1.48% 510.25 ms +4.31% -3.78% 0.18%
wpTotal 315.4 ms +1.05% -10% 297.64 ms +7.31% -5.37% 5.97%
wpMemoryUsage 14.78 MB +0% -0% 14.74 MB +0% -0% 0.27%
wpDbQueries 55 +0% -0% 55 +0% -0% 0%

site-editor

Metric 07b65bc trunk % Change
serverResponse 261.9 ms +2.07% -4.75% 255.2 ms +2.64% -7.26% 2.63%
firstPaint 315.03 ms +2.65% -3.49% 289.8 ms +9.41% -8.89% 8.71%
domContentLoaded 557.94 ms +7.62% -1.76% 552.34 ms +1.56% -1.87% 1.01%
loaded 558.74 ms +7.62% -1.77% 553.1 ms +1.56% -1.86% 1.02%
firstContentfulPaint 320.1 ms +9.73% -3.06% 317.41 ms +1.42% -0.24% 0.85%
firstBlock 1698.79 ms +3.89% -2.15% 1641.64 ms +1.65% -1.53% 3.48%
type 13.33 ms +3.53% -4.28% 14.61 ms +12.11% -4.45% -8.76%
navigate 78.85 ms +3.96% -8.1% 84.94 ms +14.03% -12.11% -7.17%
loadPatterns 988.58 ms +22.29% -9.73% 951.18 ms +8.69% -2.16% 3.93%
loadPages 918.77 ms +4.79% -2.63% 924.15 ms +0.72% -1.86% -0.58%
wpTotal 255.46 ms +1.99% -4.94% 248.91 ms +2.72% -7.47% 2.63%
wpMemoryUsage 12.79 MB +0% -0% 12.75 MB +0% -0% 0.31%
wpDbQueries 45 +0% -0% 45 +0% -0% 0%

acaae5c Run

🏁 Flaky tests

Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

Navigates the items list via UP/DOWN arrow keys in /test/e2e/specs/site-editor/dataviews-list-layout-keyboard.spec.js, passed after 1 failed attempt.
Error: expect(locator).toBeFocused() failed

Locator:  getByLabel('Page Two')
Expected: focused
Received: inactive
Timeout:  5000ms

Call log:
  - Expect "toBeFocused" getByLabel('Page Two') with timeout 5000ms
  - waiting for getByLabel('Page Two')
    14 × locator resolved to <button type="button" tabindex="-1" aria-pressed="false" id="view-list-0-172-item-wrapper" class="dataviews-view-list__item" aria-labelledby="view-list-0-172-label" aria-describedby="view-list-0-172-description"></button>
       - unexpected value "inactive"

    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/site-editor/dataviews-list-layout-keyboard.spec.js:146:49
Page List converts to Navigation Links with entity bindings in /test/e2e/specs/editor/blocks/navigation.spec.js, passed after 1 failed attempt.
Error: expect(locator).toContainText(expected) failed

Locator: getByRole('region', { name: 'Editor settings' }).getByRole('tabpanel', { name: 'Content' }).getByRole('button', { name: /Link to:/ })
Expected substring: "test-page-1"
Received string:    "Link to:Cat/catPagePublished"
Timeout: 5000ms

Call log:
  - Expect "toContainText" getByRole('region', { name: 'Editor settings' }).getByRole('tabpanel', { name: 'Content' }).getByRole('button', { name: /Link to:/ }) with timeout 5000ms
  - waiting for getByRole('region', { name: 'Editor settings' }).getByRole('tabpanel', { name: 'Content' }).getByRole('button', { name: /Link to:/ })
    14 × locator resolved to <button type="button" aria-expanded="false" aria-haspopup="dialog" class="components-button link-preview-button is-next-40px-default-size is-secondary">…</button>
       - unexpected value "Link to:Cat/catPagePublished"

    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/navigation.spec.js:1949:31
Undo restores the post selection when another synced entity is loaded (RTC WebSocket) in /test/e2e/specs/editor/collaboration/collaboration-undo-redo.spec.ts, passed after 1 failed attempt.
Error: expect(received).toMatchObject(expected)

- Expected  - 2
+ Received  + 2

  Object {
    "attributeKey": "content",
    "blockIndex": 0,
    "content": "abcdef",
-   "endOffset": 3,
-   "offset": 3,
+   "endOffset": 6,
+   "offset": 6,
  }

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/collaboration/collaboration-undo-redo.spec.ts:363:5
should select by dragging in /test/e2e/specs/editor/various/multi-block-selection.spec.js, passed after 1 failed attempt.
Error: expect(received).toEqual(expected) // deep equality

- Expected  - 1
+ Received  + 0

  Array [
    1,
-   2,
  ]

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/multi-block-selection.spec.js:533:5

From 4584d08, not the current head. Run

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new bundle can call an undefined hook when paired with an older wp.blockEditor, crashing the block screen or Blocks list.

3 open issues
What changed in this PR

Adds Transform block support with rotation as its first feature, integrating editor controls, Global Styles, and front-end rendering. Group, Heading, Image, and Video opt in.

Changes:

  • Stores rotation in style.transform.rotate and outputs the individual CSS rotate property through wrapper or feature selectors.
  • Adds Transform panels and shared responsive/state styling, while keeping enlarged lightbox images unrotated.
  • Updates schemas, documentation, changelogs, and automated coverage.
File Description
test/​integration/​theme-schema.test.js Tests permitted rotation style locations.
test/​e2e/​specs/​editor/​various/​block-transform-rotate.spec.js Covers image rotation, captions, and lightbox behavior.
schemas/​json/​theme.json Defines transform settings and styles.
schemas/​json/​block.json Defines transform support and selectors.
phpunit/​style-engine/​style-engine-test.php Tests rotation declaration generation.
phpunit/​kses-rotate-test.php Tests allowed and rejected rotation values.
phpunit/​class-wp-theme-json-test.php Tests theme rotation rendering and sanitization.
phpunit/​block-supports/​transform-test.php Tests wrapper and feature-selector output.
phpunit/​block-supports/​states-test.php Tests responsive and hover rotation output.
packages/​style-engine/​src/​types.ts Adds rotation style typing.
packages/​style-engine/​src/​test/​index.js Tests JavaScript rotation compilation.
packages/​style-engine/​src/​styles/​transform/​index.ts Validates angles and generates rotation rules.
packages/​style-engine/​src/​styles/​index.ts Registers transform style definitions.
packages/​style-engine/​src/​class-wp-style-engine.php Adds PHP angle validation and declarations.
packages/​style-engine/​CHANGELOG.md Records transform style support.
packages/​global-styles-ui/​src/​screen-block.tsx Adds the block-level Transform panel.
packages/​global-styles-ui/​src/​screen-block-list.tsx Recognizes transform-enabled blocks.
packages/​global-styles-ui/​CHANGELOG.md Records Transform panel availability.
packages/​global-styles-engine/​src/​settings/​get-setting.ts Recognizes the rotation setting.
packages/​global-styles-engine/​CHANGELOG.md Records the new setting.
packages/​blocks/​src/​api/​constants.ts Maps rotation to transform support.
packages/​blocks/​CHANGELOG.md Records rotation property metadata.
packages/​block-library/​src/​video/​README.md Documents Video rotation support and targeting.
packages/​block-library/​src/​video/​block.json Enables rotation on the video element.
packages/​block-library/​src/​image/​style.scss Resets rotation on enlarged lightbox images.
packages/​block-library/​src/​image/​README.md Documents Image rotation support and targeting.
packages/​block-library/​src/​image/​block.json Enables rotation on the image element.
packages/​block-library/​src/​heading/​README.md Documents Heading rotation support.
packages/​block-library/​src/​heading/​block.json Enables Heading rotation.
packages/​block-library/​src/​group/​README.md Documents Group rotation support.
packages/​block-library/​src/​group/​block.json Enables Group rotation.
packages/​block-editor/​src/​hooks/​utils.tsx Includes rotation in resolved block settings.
packages/​block-editor/​src/​hooks/​types.ts Defines transform hook types.
packages/​block-editor/​src/​hooks/​transform.tsx Implements inspector integration and editor styling.
packages/​block-editor/​src/​hooks/​test/​transform.jsdom.test.ts Tests transform style routing.
packages/​block-editor/​src/​hooks/​test/​style.jsdom.test.tsx Tests hover and responsive rotation rules.
packages/​block-editor/​src/​hooks/​supports.ts Recognizes transform as a style support.
packages/​block-editor/​src/​hooks/​style.tsx Integrates controls and delegates transform serialization.
packages/​block-editor/​src/​hooks/​index.ts Registers the transform editor hook.
packages/​block-editor/​src/​components/​inspector-controls/​groups.ts Adds the transform controls group.
packages/​block-editor/​src/​components/​inspector-controls-tabs/​use-inspector-controls-tabs.ts Includes transform controls in tab detection.
packages/​block-editor/​src/​components/​inspector-controls-tabs/​styles-tab.tsx Adds the Transform panel slot.
packages/​block-editor/​src/​components/​global-styles/​types.ts Defines transform settings and panel types.
packages/​block-editor/​src/​components/​global-styles/​transform-panel.tsx Adds rotation controls and angle conversion.
packages/​block-editor/​src/​components/​global-styles/​test/​transform-panel.jsdom.test.tsx Tests conversion, editing, and reset behavior.
packages/​block-editor/​src/​components/​global-styles/​index.ts Exports the Transform panel and availability hook.
packages/​block-editor/​src/​components/​global-styles/​hooks.ts Disables rotation controls where unsupported.
packages/​block-editor/​src/​components/​block-inspector/​index.tsx Adds default and state-specific Transform slots.
packages/​block-editor/​CHANGELOG.md Records the panel and inspector group.
lib/​theme.json Enables rotation controls by default.
lib/​load.php Loads transform block support.
lib/​compat/​wordpress-7.3/​kses.php Allows and validates rotation declarations.
lib/​class-wp-theme-json-gutenberg.php Handles transform settings, sanitization, and CSS output.
lib/​block-supports/​transform.php Registers attributes and renders block rotations.
docs/​reference-guides/​theme-json-reference/​theme-json-living.md Documents transform settings and styles.
docs/​reference-guides/​theme-json-reference/​styles-versions.md Records rotation version availability.
docs/​reference-guides/​core-blocks/​README.md Lists rotation support on opted-in blocks.
docs/​reference-guides/​block-api/​block-supports.md Documents the transform support contract.
docs/​how-to-guides/​themes/​global-settings-and-styles.md Adds rotation to the settings example.

Review effort: Balanced

Comment thread schemas/json/theme.json Resolved
@tellthemachines

Copy link
Copy Markdown
Contributor

Thanks for putting this together! I just did a quick round of testing and it's looking really good.

I'm not too sure about rotating only image/video without the captions, but I guess folks who want to rotate the whole block can always wrap it in a Group and rotate that instead:

Screenshot 2026-11-04 at 5 20 15 pm

This is also a great foundation to add a translate transform to, which can be used to place items anywhere on a grid without snapping them to the grid cells. So it does feel like a more solid solution than just the rotate support.

@aaronrobertshaw

Copy link
Copy Markdown
Contributor Author

Thanks for the early review @tellthemachines 🚀

I'm not too sure about rotating only image/video without the captions

Agreed. In part this helped test the block support with the selectors API and skipping serialization. Happy to allow it to go straight onto the wrapper.

This is also a great foundation to add a translate transform to

I have a draft up for this too actually. We should be able to polish and merge these pretty soon I'd say. That is, assuming we are all good with leaving Z axis translation to a follow up.

@tellthemachines

Copy link
Copy Markdown
Contributor

That is, assuming we are all good with leaving Z axis translation to a follow up.

Yeah I'm not sure we have any use for translateZ so definitely not worth adding now.

Only the blocks from the original rotate PR opt in: Group, Heading, Image and Video. The :hover rotation tests now expect Button's root selector, and the e2e spec drops the Button hover test.
theme.json and Global Styles rotations rendered on the front end but not
in the post or site editor canvas, because STYLE_KEYS left out transform.
Also lists transform in the Global Styles change summary.
A fractional angle, such as 1rad shown as 57.3, failed the angle picker's
step and was rewritten as soon as the field lost focus.
PHP's $ matched before a final newline, so "45deg\n" passed in PHP but
not in JS.
Elements already forbid transform, but their :hover and other states
referenced the complete style properties, which now include it. The PHP
side strips it, so the schema now rejects it too.
The select and text input states still referenced the complete style properties, which include transform. Guard them the same way as the link and button states.
Trunk now requires new test files to be TypeScript.
The rotation now applies to the block wrapper instead of the image or video, so the caption turns with it. The lightbox resets the rotation on the enlarged figure, which copies the block's classes and inline styles.
A rotation turns the figure around its centre, which its bounding box keeps. The zoom now places the unrotated image from that centre and the image's layout offset, instead of from the larger rotated bounding box.
… value

Use the private AnglePickerControl with min and max, so a negative angle is kept, and allowEmpty, so clearing Rotation removes the value and an inherited Global Styles rotation shows through again.
@aaronrobertshaw
aaronrobertshaw force-pushed the add/transform-block-support branch from acaae5c to 5132186 Compare November 11, 2026 07:42
@github-actions github-actions Bot added the [Package] Components /packages/components label Nov 11, 2026
@aaronrobertshaw
aaronrobertshaw changed the base branch from trunk to fix/angle-picker-control-range November 11, 2026 08:18

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Feature] Block API API that allows to express the block paradigm. [Feature] Design Tools Tools that impact the appearance of blocks both to expand the number of tools and improve the experi [Package] Block editor /packages/block-editor [Package] Block library /packages/block-library [Package] Blocks /packages/blocks [Package] Components /packages/components [Package] Style Engine /packages/style-engine [Type] Enhancement A suggestion for improvement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants