Components & interactionsTransform origin rotation
Move the pivot
Nine origin positions change how the same sheet rotates through space.

Choose a pivot and rotate the sheet
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
/**
* MIT License
*
* Copyright (c) 2026 Subframe
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
"use client"
import { useState } from "react"
export default function TransformOrigin({ active = true }: { active?: boolean }) {
const [origin, setOrigin] = useState(4),
[turned, setTurned] = useState(true),
[instant, setInstant] = useState(false)
const x = (origin % 3) * 50,
y = Math.floor(origin / 3) * 50
return (
<div className="sf-pivot" data-active={active} data-instant={instant}>
<style>{`
.sf-pivot{height:100%;min-height:300px;display:flex;align-items:center;justify-content:center;gap:50px;background:#e5e9f0;color:#273a65;font-family:Arial,sans-serif;overflow:hidden}.sf-pivot *{box-sizing:border-box}.sf-pivot-object{position:relative;width:118px;height:145px}.sf-pivot-ghost{position:absolute;inset:0;border:1px dashed #8e9bb2;border-radius:4px}.sf-pivot-sheet{position:absolute;inset:0;background:linear-gradient(135deg,#4e70b3,#2c4683);border-radius:4px;box-shadow:4px 9px 15px #17346726,inset 0 1px #99b6ef;transition:transform 400ms cubic-bezier(.2,.8,.2,1);display:flex;flex-direction:column;justify-content:center;padding:22px;color:#e8efff}.sf-pivot-sheet strong{font:italic 28px Georgia;line-height:1}.sf-pivot-sheet i{height:1px;background:#afc0e366;margin-top:14px}.sf-pivot-pin{position:absolute;width:9px;height:9px;border:2px solid #fff;background:#e89168;border-radius:50%;transform:translate(-50%,-50%);box-shadow:0 0 0 1px #ad7758;z-index:2}.sf-pivot-controls{display:flex;align-items:center;flex-direction:column;gap:20px}.sf-pivot-grid{display:grid;grid-template-columns:repeat(3,30px);padding:8px;background:#f8f9fb;border:1px solid #c8d1df;border-radius:8px;box-shadow:0 3px 10px #263b5a12}.sf-pivot-grid button{width:30px;height:30px;border:0;background:none;cursor:pointer;display:grid;place-items:center;border-radius:4px}.sf-pivot-grid button:after{content:"";width:5px;height:5px;border-radius:50%;background:#9ba9bf}.sf-pivot-grid button[aria-pressed=true]{background:#e5ebf5}.sf-pivot-grid button[aria-pressed=true]:after{background:#3b62a6;box-shadow:0 0 0 3px #a7bedf66}.sf-pivot-toggle{border:1px solid #b8c5d8;border-radius:20px;min-height:36px;padding:0 13px;color:#3e5578;background:#f4f6fa;font:11px Arial;cursor:pointer}.sf-pivot button:focus-visible{outline:2px solid #4d6fac;outline-offset:2px}.sf-pivot[data-active=false] *,.sf-pivot[data-instant=true] *{transition:none}@media(prefers-reduced-motion:reduce){.sf-pivot *{transition:none}}@media(max-width:370px){.sf-pivot{gap:30px}}
`}</style>
<div className="sf-pivot-object">
<div className="sf-pivot-ghost" />
<div
className="sf-pivot-sheet"
style={{ transformOrigin: `${x}% ${y}%`, transform: `rotate(${turned ? -20 : 0}deg)` }}
>
<strong>
Field
<br />
notes
</strong>
<i />
<i />
</div>
<div className="sf-pivot-pin" style={{ left: `${x}%`, top: `${y}%` }} />
</div>
<div className="sf-pivot-controls">
<div className="sf-pivot-grid" aria-label="Transform origin">
{Array.from({ length: 9 }, (_, i) => (
<button
type="button"
key={i}
aria-label={`${["Top", "Center", "Bottom"][Math.floor(i / 3)]} ${["left", "center", "right"][i % 3]}`}
aria-pressed={origin === i}
onClick={(e) => {
setInstant(e.detail === 0)
setOrigin(i)
}}
/>
))}
</div>
<button
type="button"
className="sf-pivot-toggle"
onClick={(e) => {
setInstant(e.detail === 0)
setTurned(!turned)
}}
>
Rotate {turned ? "back" : "−20°"}
</button>
</div>
</div>
)
}
Where it works well
Choosing a meaningful pivot for a rotating component or layered animation.
Reduced motion
Changes settle immediately; keyboard activation is immediate.
Things to know
Nine preset anchors and one rotation, not a freeform transform gizmo.