Skip to content

Add a rotation block support - #84505

Closed
draganescu wants to merge 20 commits into
trunkfrom
add/block-rotate-support
Closed

draganescu wants to merge 20 commits into
trunkfrom
add/block-rotate-support

Conversation

@draganescu

@draganescu draganescu commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor
previous (AI) description

What?

Adds a rotate block support, behind a new "Block rotation" experiment. Blocks get an optional Rotation control in their Dimensions panel unless they opt out with supports.rotate: false. The rotation follows the selected viewport (Desktop, Tablet, Mobile). Split out of #84340, where rotation started as a grid-only value; the canvas rotate handle for blocks in a manual placement grid is #84512, at the top of the grid stack, which builds on this.

Why?

Blocks can't be rotated today. This is the general support that the manual grid's canvas rotate handle builds on: the handle reads and writes the same data through the helpers added here.

How?

  • The rotation is a number of degrees in (-180, 180] stored in style.rotate. Viewport overrides live in style['@tablet'].rotate and style['@mobile'].rotate, where 0 is stored and undoes the default rotation on that viewport. In the default state, 0 is not stored.
  • The control is an AnglePickerControl in an optional ToolsPanelItem in the Dimensions panel. It edits the selected viewport, and its reset and the panel's "Reset all" only clear that viewport's rotation. It's hidden for pseudo states such as :hover.
  • Blocks are rotated with the CSS rotate property, not transform, which the editor writes to animate block moves. Rules are added under a generated class, without !important, with the default rotation first and viewport overrides in the responsive media queries. This happens in the editor through a style override and on the front end through the block supports stylesheet (lib/block-supports/rotate.php). Saved markup doesn't change.
  • Stored values are treated as untrusted, the same way in JS and PHP: only numbers and decimal numeric strings are read (no 0x10), rounded to two decimals with halves away from zero, and wrapped into (-180, 180]. rotate is added to the safe CSS properties, because the style engine filters every declaration through safecss_filter_attr().
  • A block whose own style attribute is not an object is left alone.
  • The blocks that render no wrapper of their own opt out with supports.rotate: false, mirroring the blocks that opt out of customCSS and visibility: Classic, Custom HTML, Missing, More, Page Break, Pattern, Shortcode and synced patterns (core/block). Classic would lose the rotation on save, Custom HTML and synced patterns would rotate an editor wrapper that doesn't exist on the front end, and More and Page Break have no tag.
  • The support has its own hook and PHP file rather than going through the style engine and the block states pipeline (states.php), following block visibility and custom CSS. The style engine route would need changes to the public style engine package; it would serialise an inline style into the saved markup of blocks that have other style supports; its editor output only covers blocks with style supports; and states.php emits viewport rules with !important, which would stop a canvas handle from previewing an angle with an inline style.
  • Rotation is only applied while the experiment is on, in the editor and on the front end. Turning it off keeps stored rotations, since the style attribute stays registered for rotation in JS and PHP so saving doesn't drop them, but blocks are shown unrotated. In the editor the support's hasSupport is isRotateEnabled(), which gates the control and the rotation together. On the front end, lib/load.php removes the render_block filter while the experiment is off, so rotate.php stays as it would be backported. Without this, third-party blocks that store their own style.rotate would be rotated by Gutenberg with the experiment off.
  • getRotateValue(), getRotateForState() and getUpdatedRotateStyle() read and write a style state's rotation for other editor tools. They live in utils/rotation.ts with the angle helpers, not in the hook module, so tools that only need the data don't import the hook and its controls. To keep that module free of the hook utilities (which import the components and the store), cleanEmptyObject moved from hooks/utils.tsx to utils/object.ts; hooks/utils.tsx re-exports it, so its importers and the private API are unchanged.

Known limitations:

  • While a rotated block animates to a new position after a move, the move animation's transform translates along the rotated axes, so the block briefly travels at an angle. It settles in the right place.
  • Clearing the angle field in a viewport stores 0, which unrotates the block on that viewport, rather than removing the override. Use the item's reset or "Reset all" to remove it.

Still to do before this can leave draft:

  • A Core backport PR for lib/block-supports/rotate.php (as wp_render_rotate_support() and wp_register_rotate_support()) and the rotate entry in safe_style_css: Editor: Add the rotation block support wordpress-develop#14707 (draft), with backport-changelog/7.2/14707.md. The supports.rotate: false opt-outs in Core's block.json files arrive with the package update.
  • Once Core ships its version, have lib/block-supports/rotate.php remove Core's render_block filter before adding its own, as block-visibility.php and custom-css.php do.
  • Decide whether to document supports.rotate in schemas/json/block.json and the block supports reference while it is experimental.

Testing Instructions

  1. In Gutenberg > Experiments, turn on "Block rotation".
  2. Add a Paragraph and an Image. In the block settings, open the Dimensions panel's options menu and turn on Rotation.
  3. Set an angle and check the block rotates on the canvas. Save and check it rotates on the front end.
  4. Set the angle back to 0 and check that style.rotate is removed from the block's attributes (Code editor).
  5. Switch the preview to Mobile and set a different angle, then 0. Check the block is unrotated on mobile only, on the canvas and on the front end at a narrow width.
  6. In Mobile, use "Reset all" in the Dimensions panel and check that the desktop rotation is kept.
  7. Turn the experiment off and check the control is gone and rotated blocks are shown unrotated, on the canvas and on the front end. Edit and save the post, turn the experiment back on, and check the blocks are rotated again.
  8. Add a Custom HTML, Classic, Shortcode and More block, and a synced pattern, and check they have no Rotation control.

Testing Instructions for Keyboard

Tab to the Dimensions panel's options menu, turn on Rotation, then type an angle into the Rotation number field. Check that Tab moves through the panel and that "Reset all" works from the menu.

What

Adds a Rotation block support and enables it on the following blocks:

  • Image
  • Video
  • Heading
  • Group

Although rotation is its own support, in the block inspector it sits under the "Position" section, so now that section will be visible for any block that has rotation even if it doesn't have position. Is this weird? 😅

One thing I'm noticing is when rotating a grid block, in the editor the visualizer stays unrotated.

Rotated blocks partially overlap other blocks. This isn't necessarily a problem, because margins can be used to adjust it.

Rotated blocks can cause horizontal scroll on smaller breakpoints. Again, this is something that's already possible with widths and margins, so might not be a real issue.

Testing Instructions

  1. Add an Image, a Video, a Heading and a Group block. In the block settings, open the Position panel's options menu and turn on Rotation.
  2. Set an angle on each block and check it rotates on the canvas. Save and check it rotates on the front end.
  3. Set the angle back to 0 and check that style.rotate is removed from the block's attributes (Code editor).
  4. Add a Paragraph and check it has no Rotation control. Blocks have to opt in with supports.rotation: true.
  5. Switch the preview to Mobile and set a different angle on a block, then 0. Check the block is unrotated on mobile only, on the canvas and on the front end at a narrow width.
  6. In Mobile, use "Reset all" in the Position panel and check that the desktop rotation is kept.
  7. Select a hover or focus state for a block and check the Rotation control is hidden.
  8. Check the Position panel shows for a rotation-only block such as Image, which has no position support.

Testing Instructions for Keyboard

Tab to the Position panel's options menu, turn on Rotation, then type an angle into the Rotation number field. Check that Tab moves through the panel and that "Reset all" works from the menu.

Screenshots or screencast

Screenshot 2026-10-27 at 5 17 35 pm

Use of AI Tools

Written with Claude Code (Claude Opus 5.5), split out of #84340. The design decisions (a general support stored in style.rotate, a separate experiment) were made in conversation with the PR author.

🤖 Generated with Claude Code

Iterations written with copilot/ gpt 6.1 terra and claude sonnet 5.5

@github-actions github-actions Bot added [Package] Block library /packages/block-library [Package] Block editor /packages/block-editor labels Oct 10, 2026
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

🤖 PR meta 🤖

🎉 Props

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: draganescu <andraganescu@git.wordpress.org>
Co-authored-by: tellthemachines <isabel_brison@git.wordpress.org>
Co-authored-by: ramonjd <ramonopoly@git.wordpress.org>
Co-authored-by: aaronrobertshaw <aaronrobertshaw@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.


Props Bot commands

You can trigger Props Bot actions by commenting on this PR:

  • @propsbot add <username> to give props to a GitHub user. Requires write access.
  • @propsbot remove <username> to leave a user out of the props. Requires write access.
  • @propsbot refresh to refresh this list. Adding the props-bot label works too.

Updated as activity occurs, without notifying anyone named here. Add the props-bot label to refresh.

🏷️ Labels

This pull request needs exactly one label indicating its type, and has 0.

  • Required: any label starting with [Type].
  • Found: none.

Read more about Type labels in Gutenberg. If you cannot add labels yourself, a reviewer can do it for you.

📦 Bundle size

Size Change: +1.08 kB (+0.01%)

Total Size: 8.58 MB

📦 View Changed
Filename Size Change
build/scripts/block-editor/index.min.js 543 kB +1.07 kB (+0.2%)
build/scripts/block-library/index.min.js 415 kB +17 B (0%)

216d8bf Run

⚡ Performance

Show the results

Client side metrics exclude the server response time.

front-end-block-theme

Metric b03b7c0 trunk % Change
timeToFirstByte 35.15 ms +11.24% -5.41% 33.5 ms +15.37% -5.22% 4.93%
largestContentfulPaint 62 ms +3.23% -3.23% 60 ms +6.67% -6.67% 3.33%
lcpMinusTtfb 26 ms +14.42% -9.23% 25.8 ms +13.57% -7.75% 0.78%
wpBeforeTemplate 18.06 ms +17.77% -4.1% 17.11 ms +19.23% -2.57% 5.55%
wpTemplate 13.43 ms +4.54% -5.88% 12.91 ms +8.06% -6.2% 4.03%
wpTotal 32.24 ms +10.14% -5.99% 30.63 ms +15.25% -5.42% 5.26%
wpMemoryUsage 7.71 MB +0% -0% 7.67 MB +0% -0% 0.46%
wpDbQueries 16 +0% -0% 16 +0% -0% 0%

front-end-classic-theme

Metric b03b7c0 trunk % Change
timeToFirstByte 26.2 ms +7.63% -4.77% 25.5 ms +7.45% -3.53% 2.75%
largestContentfulPaint 58 ms +3.45% -3.45% 56 ms +3.57% -0% 3.57%
lcpMinusTtfb 31.2 ms +4.33% -2.08% 31.1 ms +1.61% -1.93% 0.32%
wpBeforeTemplate 15.83 ms +9.67% -2.15% 15.6 ms +4.87% -2.88% 1.47%
wpTemplate 7.38 ms +3.66% -4.88% 7.25 ms +6.21% -3.72% 1.79%
wpTotal 23.74 ms +7.96% -4.3% 22.98 ms +8.57% -2.92% 3.31%
wpMemoryUsage 6.29 MB +0% -0% 6.25 MB +0% -0% 0.58%
wpDbQueries 9 +0% -0% 9 +0% -0% 0%

media-processing

Metric b03b7c0 trunk % Change
mediaProcessingJpeg 211.39 ms +2.81% -0.29% 220.76 ms +1.8% -0.98% -4.24%
mediaProcessingAvif 3498.4 ms +0.02% -0.62% 3504.58 ms +0.18% -0.27% -0.18%
mediaProcessingJpegToAvif 2426.49 ms +0.36% -0.25% 2443.2 ms +1.19% -0.16% -0.68%

media-upload

Metric b03b7c0 trunk % Change
jpegUploadProcessing 1388.64 ms +1.96% -1.07% 1390.4 ms +1.07% -0.87% -0.13%
pngUploadProcessing 172.69 ms +6.18% -1.11% 173.48 ms +6.76% -4.66% -0.46%
largeJpegUploadProcessing 1403.35 ms +0.18% -0.93% 1392.51 ms +1.07% -0.4% 0.78%
multipleImageUploadProcessing 1437.33 ms +1.67% -0.17% 1433.34 ms +1.09% -0.49% 0.28%

post-editor

Metric b03b7c0 trunk % Change
serverResponse 481.16 ms +3.38% -5.92% 437.44 ms +12.35% -0.51% 9.99%
firstPaint 355.61 ms +32.63% -15.63% 361.37 ms +24.47% -6.08% -1.59%
domContentLoaded 843.44 ms +3.89% -0.69% 847.32 ms +2.11% -1.13% -0.46%
loaded 844.56 ms +3.85% -0.69% 848.44 ms +2.1% -1.15% -0.46%
firstContentfulPaint 489.41 ms +6.89% -1.47% 492.42 ms +4.64% -1.64% -0.61%
firstBlock 2211.79 ms +1.41% -1.37% 2171.18 ms +2.38% -0.38% 1.87%
type 18.63 ms +3.92% -7.25% 17.01 ms +10.64% -10.93% 9.52%
typeWithoutInspector 17.39 ms +4.77% -8.45% 18.12 ms +3.75% -10.26% -4.03%
typeWithTopToolbar 23.36 ms +4.62% -6.08% 23.21 ms +7.37% -7.97% 0.65%
typeContainer 8.5 ms +5.76% -4.59% 8.58 ms +11.54% -13.52% -0.93%
focus 123.55 ms +3.76% -7.57% 117.41 ms +5.98% -2.43% 5.23%
firstFocus 259.48 ms +0% -0% 262.95 ms +0% -0% -1.32%
selectAll 418.92 ms +1.27% -0.56% 411.17 ms +1.62% -4.44% 1.88%
undo 30.53 ms +3.7% -4.16% 29.54 ms +3.22% -6.7% 3.35%
listViewOpen 85.59 ms +1.5% -16.44% 78.23 ms +6.94% -10.65% 9.41%
inserterOpen 41.16 ms +9.16% -7.87% 42.48 ms +3.84% -12.03% -3.11%
inserterHover 11.08 ms +16.16% -10.83% 11.25 ms +7.38% -9.69% -1.51%
inserterSearch 8.88 ms +2.48% -4.73% 8.47 ms +7.67% -8.38% 4.84%
loadPatterns 650.45 ms +1.11% -5.49% 621.85 ms +13.11% -6.56% 4.6%
wpTotal 471.6 ms +3.18% -6.08% 427.79 ms +12.39% -0.53% 10.24%
wpMemoryUsage 14.75 MB +0% -0% 14.72 MB +0% -0% 0.25%
wpDbQueries 55 +0% -0% 55 +0% -0% 0%

site-editor

Metric b03b7c0 trunk % Change
serverResponse 480.96 ms +0.68% -6.54% 461.99 ms +4.75% -4.99% 4.11%
firstPaint 372.83 ms +1.59% -15.79% 368.01 ms +6.75% -13.84% 1.31%
domContentLoaded 879.84 ms +0.89% -2.33% 885.52 ms +0.87% -1.57% -0.64%
loaded 880.77 ms +0.88% -2.32% 886.46 ms +0.89% -1.57% -0.64%
firstContentfulPaint 502.99 ms +1.44% -2.27% 502.43 ms +2.13% -1.12% 0.11%
firstBlock 2592.35 ms +7.48% -1.38% 2598.93 ms +0.67% -0.88% -0.25%
type 19.12 ms +2.98% -6.38% 18.76 ms +0.43% -7.57% 1.92%
navigate 120.59 ms +4.93% -6.48% 122.87 ms +6.2% -3.84% -1.86%
loadPatterns 1427.25 ms +8.64% -18.24% 1434.82 ms +5.51% -1.98% -0.53%
loadPages 1020.9 ms +1.65% -3.12% 1019.83 ms +1.34% -1.47% 0.1%
wpTotal 471.49 ms +0.64% -6.66% 452.39 ms +4.84% -5.04% 4.22%
wpMemoryUsage 12.77 MB +0% -0% 12.73 MB +0% -0% 0.29%
wpDbQueries 45 +0% -0% 45 +0% -0% 0%

216d8bf 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-175-item-wrapper" class="dataviews-view-list__item" aria-labelledby="view-list-0-175-label" aria-describedby="view-list-0-175-description"></button>
       - unexpected value "inactive"

    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/site-editor/dataviews-list-layout-keyboard.spec.js:146:49
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
inserts a block should show a blue line indicator in /test/e2e/specs/editor/various/inserting-blocks.spec.js, passed after 1 failed attempt.
TimeoutError: locator.boundingBox: Timeout 10000ms exceeded.
Call log:
  - waiting for locator('data-testid=block-list-insertion-point-indicator')

    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/inserting-blocks.spec.js:678:41
should preserve dragged selection on move in /test/e2e/specs/editor/various/multi-block-selection.spec.js, passed after 1 failed attempt.
TimeoutError: locator.hover: Timeout 10000ms exceeded.
Call log:
  - waiting for locator('[name="editor-canvas"]').contentFrame().getByRole('document', { name: 'Block: Paragraph' }).nth(1)
    - locator resolved to <p role="document" data-empty="false" aria-multiline="true" aria-readonly="false" data-title="Paragraph" contenteditable="true" data-type="core/paragraph" aria-label="Block: Paragraph" data-wp-block-attribute-key="content" id="block-66e10fae-899b-4562-8ce0-45df83e8a325" data-block="66e10fae-899b-4562-8ce0-45df83e8a325" class="block-editor-rich-text__editable block-editor-block-list__block wp-block wp-block-paragraph rich-text">2</p>
  - attempting hover action
    2 × waiting for element to be visible and stable
      - element is visible and stable
      - scrolling into view if needed
      - done scrolling
      - <button id="_r_5u_" type="button" tabindex="-1" aria-label="Link" aria-pressed="false" aria-haspopup="true" aria-expanded="false" data-toolbar-item="true" class="components-button components-toolbar-button is-compact has-icon">…</button> from <div tabindex="-1" data-wp-c16t="true" data-wp-component="Popover" class="components-popover block-editor-block-popover block-editor-block-list__block-popover is-positioned is-unstyled">…</div> subtree intercepts pointer events
    - retrying hover action
    - waiting 20ms
    2 × waiting for element to be visible and stable
      - element is visible and stable
      - scrolling into view if needed
      - done scrolling
      - <button id="_r_5u_" type="button" tabindex="-1" aria-label="Link" aria-pressed="false" aria-haspopup="true" aria-expanded="false" data-toolbar-item="true" class="components-button components-toolbar-button is-compact has-icon">…</button> from <div tabindex="-1" data-wp-c16t="true" data-wp-component="Popover" class="components-popover block-editor-block-popover block-editor-block-list__block-popover is-positioned is-unstyled">…</div> subtree intercepts pointer events
    - retrying hover action
      - waiting 100ms
    15 × waiting for element to be visible and stable
       - element is visible and stable
       - scrolling into view if needed
       - done scrolling
       - <button id="_r_5u_" type="button" tabindex="-1" aria-label="Link" aria-pressed="false" aria-haspopup="true" aria-expanded="false" data-toolbar-item="true" class="components-button components-toolbar-button is-compact has-icon">…</button> from <div tabindex="-1" data-wp-c16t="true" data-wp-component="Popover" class="components-popover block-editor-block-popover block-editor-block-list__block-popover is-positioned is-unstyled">…</div> subtree intercepts pointer events
     - retrying hover action
       - waiting 500ms

    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/multi-block-selection.spec.js:817:20

216d8bf Run

@draganescu draganescu left a comment •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

A cursory code reading - didn't spot any glaring problems, mostly it copies other supports implementations.

@draganescu draganescu added the No Core Sync Required Indicates that any changes do not need to be synced to WordPress Core label Oct 21, 2026
@tellthemachines

Copy link
Copy Markdown
Contributor

Quick notes from testing: this is generally working very well! I'd try and ship this as a stable rather than behind an experiment flag, provided we make it opt-in instead of opt-out. This is very important because making it opt-out as it currently is on this branch can really mess things up for third party blocks that aren't prepared to support this kind of behaviour.

Besides, this doesn't work super well for all types of blocks. I'd opt in for a select few where it works pretty well for now (Image, perhaps even Gallery, Button/Buttons, Video, Heading, Paragraph, maybe Quote, and Group so we can make fun shapes) and leave the rest out for gradual adoption. On some blocks e.g. Accordion things can get badly messy:

Screenshot 2026-10-22 at 3 14 41 pm

And for Spacer I don't think it makes sense at all.

In regards to the canvas experiment, this could mean rotation isn't available for all block types. I don't think this is necessarily a problem, especially with more niche blocks.

One thing to consider is editor UX once items are rotated, because they can overlap other block controls:

Screenshot 2026-10-22 at 3 03 12 pm

I'm not sure how this could best be solved, and we should be mindful of allowing folks to overlap blocks deliberately, so maybe it's something to not treat as a problem for now.

Regarding the placement of the control in the inspector, I don't think "dimensions" really makes sense conceptually. I'd put it under "position" instead (it can still be its own support because there's not much overlap with the position logic, but "position" is a more likely header to put "rotation" under IMO)

I'm happy to add changes to the PR myself if you agree with this direction!

@draganescu draganescu removed the No Core Sync Required Indicates that any changes do not need to be synced to WordPress Core label Oct 22, 2026
@tellthemachines
tellthemachines force-pushed the add/block-rotate-support branch from 7f94fc3 to 41b6a94 Compare October 27, 2026 03:16
@tellthemachines tellthemachines self-assigned this Oct 27, 2026
@tellthemachines tellthemachines changed the title Block Supports: Add an experimental rotate block support Add a rotation block support Oct 27, 2026
@tellthemachines

Copy link
Copy Markdown
Contributor

I've pushed updates to address my previous feedback, but haven't human reviewed yet and need to do more testing before marking ready.

@draganescu

Copy link
Copy Markdown
Contributor Author

I think we should just land this as it copies basically other supports and we can think of how to solve overlapping and even move the control in the inspector where we want to if we find solo position item to be weird.

@tellthemachines

Copy link
Copy Markdown
Contributor

We don't land things without human review of the code. That was why I marked it as draft. Have you reviewed it?

I don't think it's a good idea to spend time on this until the MVP functionality I outlined in #81796 is all merged and stable. This is a cool feature, but it's not essential to canvas. I'm fine with trying to get it into 7.3, but we need the essentials in place first or we risk not shipping anything.

@draganescu draganescu left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Gave the code a read and undid some pointless refactoring. Otherwise LGTM @tellthemachines

Comment thread lib/block-supports/rotation.php Resolved
Comment thread lib/block-supports/rotation.php Resolved
Comment thread lib/block-supports/rotation.php Resolved
Comment thread lib/compat/wordpress-7.2/kses.php Resolved
draganescu and others added 20 commits November 3, 2026 16:01
Every block that doesn't opt out with `supports.rotate: false` can now be
rotated, behind the new "Block rotation" experiment
(`gutenberg-block-rotation`, which sets
`window.__experimentalEnableBlockRotation`). The Dimensions panel gets an
optional Rotation item with an angle picker.

The rotation is a number of degrees in (-180, 180] stored in
`style.rotate`. It follows the selected viewport style state: overrides
live in `style['@tablet'].rotate` and `style['@mobile'].rotate`, where 0
is stored and undoes the default rotation on that viewport. In the
default state, 0 is not stored. "Reset all" only clears the selected
state's rotation.

Blocks are rotated with the CSS `rotate` property rather than
`transform`, which the editor writes to animate block moves. In the
editor, the rules are added under a generated class, with viewport
overrides in the responsive media queries. On the front end,
`lib/block-supports/rotate.php` adds the same rules to the block supports
stylesheet under a class generated from them, treating the stored value as
untrusted. Saved markup doesn't change, and stored rotation is shown
whether or not the experiment is on.

`getRotateForState()` and `getUpdatedRotateStyle()` read and write the
rotation of a style state, for tools such as a canvas rotate handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Classic, Custom HTML, Missing, More, Page Break, Pattern, Shortcode and
synced patterns opt out with `supports.rotate: false`, like they opt out
of custom CSS and visibility: they have no wrapper of their own to
rotate, or lose it on save or on the front end.

A block whose own `style` attribute is not an object doesn't support
rotation, and style values that are not objects are read as no
rotation.

JS reads stored values like PHP: only decimal numeric strings, and
halves rounded away from zero after removing floating point noise, so
-12.345 is -12.35 on both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PHPCompatibility flags the '0x10' test value, which checks that
hexadecimal strings are rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PHP 7 rejects numeric strings with trailing whitespace, while PHP 8
accepts them, so the PHP 7.4 test run failed on ' 15 '.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trunk types useSettings as returning unknown values, so the viewport
setting is cast to the record getRotateCSS expects, as other typed callers
of useSettings do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rotate hook registered its own filter adding the style attribute. When
the hook was imported early, such as by the grid's mobile stacking, the
filter ran before the other block supports and moved the style attribute
ahead of theirs, which changed serialized fixtures, and it added the
attribute in tests that only load another block support. The style hook's
filter now also adds it for rotation, so it stays in the same place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rename the rotate block support and rotation utility tests to TypeScript so
they pass the `gutenberg/use-typescript` lint rule for new files. The tests
check the same things: block types are built with a typed helper, the block
rotation experiment flag is read through a typed window, and the cases that
pass a style attribute that is not an object use `@ts-expect-error`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rotate render_block filter and the editor's useBlockProps ran with the
experiment off, so blocks storing style.rotate were rotated anyway. The
front-end filter is now removed in lib/load.php while the experiment is off,
which keeps rotate.php as it would be backported, and the editor support's
hasSupport is isRotateEnabled, which gates both the control and the
rotation. The style attribute is still registered for rotation on both
sides, so a stored rotation is kept rather than dropped on save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Remove core's wp_render_rotate_support filter when it exists, as the
custom CSS and block visibility supports do, so a WordPress version that
ships rotation doesn't output the rules twice.

The angle picker now rounds to two decimals, the same as stored
rotations, through a shared roundAngle helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`getRotateValue`, `getRotateForState` and `getUpdatedRotateStyle` read and
write `style.rotate` without any React or UI code, but lived in the rotate
hook module, which imports the inspector controls. Grid code that only
needs the data helpers had to import that module. They now live in
`utils/rotation.ts` with the other angle helpers. The rotation utils test
runs in jsdom, since the block style state helpers need it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The block style state helpers only needed `hooks/utils` for
`cleanEmptyObject`, and that module imports the components and the store.
Through them, `utils/rotation.ts` couldn't load outside a browser
environment. `cleanEmptyObject` is a plain object helper, so it now lives
in `utils/object.ts`, and `hooks/utils` re-exports it for its existing
importers. The rotation utils test runs in node again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ribute

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moving it into utils/object only kept the rotation utils test running in Node. Run that test in jsdom instead, like the other rotation tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Core is past the 7.2 feature freeze, so the safe CSS entry moves to the 7.3 compat file and the backport changelog entry to 7.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tellthemachines
tellthemachines force-pushed the add/block-rotate-support branch from 14f5f58 to 216d8bf Compare November 3, 2026 05:04
```js
supports: {
// Enable the Rotation control.
rotation: true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Minor - the support key is rotation, but the value is stored as style.rotate. Should they match?

@aaronrobertshaw aaronrobertshaw 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.

Thanks for all the work on this, I'd really like to see rotation land 👍

I've taken it for a spin. The editor side works well but this isn't ready to merge as it stands.

Once blocks save rotation, most of the issues below get locked in. They need fixing before it lands.

Blockers

  • This should be a "Transform" block support with a rotate feature, like "Dimensions" has width and height. That gives style.transform.rotate, leaves room for scale or skew, and settles the rotation vs rotate naming.
  • It isn't a registered style property, and has no style engine or theme.json support. Themes can't disable it, set it in styles or use it in a style variation.
  • No skip serialization, which every other block support offers.
  • No default vs optional control display. The support only accepts true.
  • The value is a bare number with deg hard-coded. "45deg" is silently ignored. We should have proper CSS unit support from the start as this has added complexity to other supports when inevitably updating them later e.g. border radius.
  • A new public position-tools inspector group. Image gets a Position panel holding only Rotation. It is weird to duplicate "Position" panels effectively. If this must be in a Position panel, why not the actual existing one?
  • Image and Video rotate the whole figure, caption included. Without skip serialization or selectors, they can't target the img.
  • A rotated Image with lightbox opens rotated and clipped in the overlay.

Some further details are inline.

A pattern to follow

#76949 added min width in one PR: style engine, theme.json, both schemas, Global Styles and tests.

)
);

$processor->add_class( $class_name );

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.

This breaks the lightbox on a rotated Image.

The rotate class lands on the figure, and the lightbox copies the figure's classes onto the overlay. So the zoomed image opens rotated and clipped.

To reproduce:

  1. Add an Image, turn on "Enlarge on click" and rotate it.
  2. Publish and click the image on the front end.

The overlay needs to stay unrotated, with an e2e test covering it.

* @param array $responsive_media_queries Media queries keyed by viewport state, e.g. `@mobile`.
* @return array[] CSS rules with `declarations` and, for viewport overrides, `rules_group`.
*/
function gutenberg_get_rotate_style_rules( $style, $responsive_media_queries ) {

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.

Rotation is built by hand here and again in getRotateCSS(). It isn't in the style engine or theme.json.

That leaves gaps other supports don't have:

  • Themes can't turn the control off, like settings.position.sticky.
  • styles.blocks.*.rotate and block style variations are dropped.
  • Global Styles can't show or set it.

New block supports should go through the style engine with a theme.json setting and style. That should be in place before blocks opt in.


if ( $rotate ) {
$rules[] = array(
'declarations' => array( 'rotate' => $rotate . 'deg' ),

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.

I don't think we should store rotation as a bare number with deg added on output.

Other style values are CSS strings, like minHeight: "50vh" or a border width of "1px". Here a hand-written "45deg" or "0.25turn" is silently ignored, in the editor and on the front end. Themes will set whatever string they like in theme.json.

Once blocks save this shape, changing it needs a deprecation per block type or forking logic within the block support which is complexity we don't need to carry or maintain.

Let's store a CSS angle string and have the UI write deg or support selected units.

layout: InspectorControlsLayout,
list: InspectorControlsListView,
position: InspectorControlsPosition,
'position-tools': InspectorControlsPositionTools,

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.

This adds a public position-tools group next to position. Any plugin can fill it, so it's API we'd have to keep.

It also reads oddly on screen. On a Group, the panel menu lists "Position" inside "Position".

The open question in the description about this hasn't been settled. Let's decide where Rotation belongs first. To me, this 100% should be part of a "Transform" block support and therefore a "Rotate" control within a "Transform" panel.

}
},
"supports": {
"rotation": true,

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.

On Image this rotates the whole figure, caption included. Video does the same.

Other visual supports on Image target the img through selectors and skip serialization.

The support only accepts true, so a block can't pick the element or show the control by default. Is rotating the caption intended?

Comment on lines +135 to +138
$block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] );
if ( ! $block_type || ! block_has_support( $block_type, 'rotation', false ) ) {
return $block_content;
}

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.

This always adds the rotate class to the block wrapper. There's no way for a block to skip serialization.

Every other style support checks __experimentalSkipSerialization, for example dimensions. The editor hook needs the same check.

Blocks rely on that to put a style on an inner element or conditionally apply it. Image puts border and shadow on the inner img. Without it, Image can't rotate the img instead of the figure, and the lightbox issue has no clean fix.

Let's support skip serialization in PHP and JS, with tests, the same as other supports.

! hasStyleSupport( settings ) &&
! hasBlockSupport( settings, 'customCSS', true )
! hasBlockSupport( settings, 'customCSS', true ) &&
! hasBlockSupport( settings, 'rotation', false )

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.

Rotation is added here as a special case, not as a style property. It isn't in __EXPERIMENTAL_STYLE_PROPERTY or the style support keys.

That list is what the rest of the styles system reads. Global Styles output and block style variations both go through it, so neither knows about rotation.

Registering it there, like minWidth, is what brings that support with it. It also needs the matching style engine and theme.json tests, as #76949 had.

@aaronrobertshaw

Copy link
Copy Markdown
Contributor

@draganescu and @tellthemachines I've put together #86240 as an alternative way forward. It covers most of the feedback from my review.

  • A new Transform block support, with rotate as its first feature. Scale or skew can follow without a schema change.
  • Style engine and theme.json support. Themes can turn it off, set it in styles and use it in variations.
  • CSS angle values, output with the individual CSS rotate property.
  • Skip serialization, default controls and feature selectors. Image and Video rotate the img and video, so captions stay level (I don't have a strong opinion on what's best here though).
  • Its own Transform panel in the block inspector and Global Styles.
  • :hover and viewport rotation through the shared block states code.

It opts in the same four blocks.

If we go that way, we could move these changes into this PR. It's probably easier to close this one in favour of #86240 though, with credit for the work here. Happy to go either way.

@draganescu

Copy link
Copy Markdown
Contributor Author

That's the way to go. It's just faster to code than review 😂 particularly if you're the center of expertise. Closing this in favour of #86240 - thank you @aaronrobertshaw

I think rotate is not a nice to have, it's fundamental to a delightful canvas experience and it's one of the few "oh look at that" things we can say about this canvas.

@draganescu draganescu closed this Nov 3, 2026
@aaronrobertshaw

Copy link
Copy Markdown
Contributor

Sounds like we have a plan. I can refine and push #86240 along over the next day or two.

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

Labels

[Package] Block editor /packages/block-editor [Package] Block library /packages/block-library

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants