├── .gitignore ├── .npmrc ├── LICENSE ├── LICENSE.md ├── README.md ├── feature.png ├── index.css ├── index.ts ├── logo.png ├── package-lock.json ├── package.json ├── patches └── tailwindcss-v4+4.1.5.patch ├── tsconfig.json └── utils.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix = '' -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Brandon McConnell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2025 Brandon McConnell 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
Anchors for Tailwind CSSAnchors for Tailwind CSS
2 | 3 | 11 | 12 | --- 13 | 14 | **Anchors for Tailwind CSS** is a plugin that brings declarative support for the **CSS Anchor Positioning API** to Tailwind CSS. It provides utilities for defining anchors (`anchor-name`) and positioning elements relative to them (`position-anchor`, `position-area`, `anchor()`, `anchor-size()`), as well as utilities for advanced features like conditional visibility (`position-visibility`) and fallback positioning (`position-try-order`, `position-try-fallbacks`). 15 | 16 | 17 | It also lays the groundwork for using View Transitions to animate any anchored elements, which would require separate JS (for now 👀). 18 | 19 | ## Installation 20 | 21 | 1. Install the plugin from npm with your preferred package manager: 22 | 23 | ```bash 24 | npm install -D @toolwind/anchors 25 | ``` 26 | 2. Then include it in your Tailwind CSS or JS config file: 27 | 28 |
CSS (Tailwind CSS v4+) 29 | 30 | ```css 31 | /* style.css */ 32 | @import "@toolwind/anchors"; 33 | ``` 34 | 35 |
JS (Tailwind CSS v3 compatible) 36 | 37 | ```js 38 | // tailwind.config.js 39 | import anchors from "@toolwind/anchors"; 40 | ``` 41 | 42 |
43 | 44 | ## Usage 45 | 46 | ### Defining an anchor 47 | 48 | Use the `anchor/{name}` utility to define an anchor point. The CSS `anchor-name` property requires a dashed ident type (e.g. `--my-anchor`). For convenience, Anchors for Tailwind CSS automatically converts simpler ident types (e.g. `my-anchor`) names into dashed idents under the hood. 49 | 50 | There are several ways to define an anchor: 51 | 52 | 1. **Use a non-dashed ident** (e.g. `my-anchor`) 53 | 54 | ```html 55 |
56 | ``` 57 | 58 | This utlity generates the below CSS, prefixed with `--tw-anchor_` since CSS `anchor-name` property requires a dashed ident type (e.g. `--my-anchor`), as mentioned previously: 59 | 60 | ```css 61 | .anchor\/my-anchor { 62 | anchor-name: --tw-anchor_my-anchor; 63 | } 64 | ``` 65 | 66 | 2. **Use a dashed ident** (e.g. `--my-anchor`) 67 | 68 | If you explicitly specify a dashed ident as an anchor name, this utility will preserve that dashed ident so you can re-use it elsewhere more easily. 69 | 70 | ```html 71 |
72 | ``` 73 | 74 | This will generate the following CSS: 75 | 76 | ```css 77 | .anchor\/--my-anchor { 78 | anchor-name: --my-anchor; 79 | } 80 | ``` 81 | 82 | 3. **Use an arbitrary value** (e.g. `[var(--x)]` or `[--my-anchor]`) 83 | 84 | 🚧 Note that names passed via an arbitrary value (square bracket syntax) must be or resolve to a dashed ident. 85 | 86 | 1. **Include a variable reference inside an arbitrary value** (e.g. `[var(--x)]`) 87 | 88 | If you want to pass a name via a CSS variable, you can do so using an arbitrary value (square bracket syntax), like this: 89 | 90 | ```html 91 |
92 |
93 |
94 | ``` 95 | 96 | This will generate the following CSS: 97 | 98 | ```css 99 | .anchor\/\[var\(--x\)\] { 100 | anchor-name: var(--x); 101 | } 102 | .\[--x\:--my-anchor\] { 103 | --x: var(--my-anchor); 104 | } 105 | ``` 106 | 107 | For an even simpler syntax, take a look at the variable shorthand syntax under point **4** below. 108 | 109 | 2. **Include dashed ident inside arbitrary value** (e.g. `[--my-anchor]`) 110 | 111 | You can directly pass a dashed ident using the square bracket syntax: 112 | 113 | ```html 114 |
115 | ``` 116 | 117 | This will generate the following CSS: 118 | 119 | ```css 120 | .anchor\/\[--my-anchor\] { 121 | anchor-name: --my-anchor; 122 | } 123 | ``` 124 | 125 | However, while this approach works, it is equivalent to using the dashed ident syntax shown in example 2 (`anchor/--my-anchor`), which is simpler. 126 | 127 | > [!WARNING] 128 | > Note that Tailwind v3.x treats this syntax as differently, and will process `anchor/[--my-anchor]` as `anchor-name: var(--my-anchor)` instead of `anchor-name: --my-anchor`, as it uses this as the syntax for variable shorthand syntax. See point **4** below for more information. 129 | 130 | 4. **Pass a variable using variable shorthand syntax** 131 | 132 | 1. Tailwind CSS v4.x 133 | 134 | In Tailwind CSS v4.x and above, you can use this shorthand below instead of passing a custom property reference to an arbitrary value (square bracket syntax). 135 | 136 | These two utilities are equivalent, and will both produce `anchor-name: var(--x)`: 137 | 138 | ```html 139 |
140 |
141 | ``` 142 | 143 | > [!IMPORTANT] 144 | > This is not the same as `anchor/--my-anchor` or `anchor/[--my-anchor]`, as they pass the dashed ident itself directly, where the variable shorthand syntax wraps its value in `var()`. 145 | 146 | 2. Tailwind CSS v3.x (≥ 3.3 and above) 147 | 148 | Using variable shorthand syntax in Tailwind v3.x (≥ 3.3 and above), you can use square brackets to reference CSS variables, similarly to the v4 example above, only using square brackets instead of parentheses. 149 | 150 | These two utilities are equivalent, and will both produce `anchor-name: var(--x)`: 151 | 152 | ```html 153 |
154 |
155 | ``` 156 | 157 | > [!IMPORTANT] 158 | > This behavior differs from Tailwind CSS v4 and newer, where `anchor/[--x]` would pass the dashed ident directly. In Tailwind CSS v3, if you want to pass the dashed ident directly without wrapping it in `var()`, use the dashed ident syntax shown in example 2 above. 159 | 160 | ### Positioning relative to an anchor 161 | 162 | Once an anchor has been defined, you can anchor other elements to it. 163 | 164 | * **Anchoring:** Attach an element to an anchor 165 | 166 | Use `anchored/{name}` to attach an element to an anchor: 167 | 168 | ```html 169 |
170 | ``` 171 | ```css 172 | { 173 | position-anchor: --tw-anchor_my-anchor; 174 | :where(&) { 175 | position: absolute; 176 | view-transition-name: --tw-anchor-view-transition-313d192p322r2w3336; 177 | } 178 | } 179 | ``` 180 | 181 | * **Positioning:** Position an element relative its linked anchor 182 | 183 | Use `anchored-{side}` to specify the position area of the anchored element. For example, `anchored-top-center` will position the element at the top center of its anchor, touching the anchored element: 184 | 185 | ```html 186 |
187 | ``` 188 | ```css 189 | { 190 | position-area: top center; 191 | } 192 | ``` 193 | 194 | * **Shorthand:** Anchor _and_ position an element relative to an anchor 195 | 196 | Use `anchored-{side}/{name}` to combine anchoring and positioning in a single utility: 197 | 198 | ```html 199 |
200 | ``` 201 | ```css 202 | { 203 | position-anchor: --tw-anchor_my-anchor; 204 | position-area: top center; 205 | :where(&) { 206 | position: absolute; 207 | view-transition-name: --tw-anchor-view-transition-313d192p322r2w3336; 208 | } 209 | } 210 | ``` 211 | 212 | > [!IMPORTANT] 213 | > A quick note on the use of `:where()` here: 214 | > 215 | > Using `:where()` in a CSS selector resets the specificity of the styles declared inside that selector's block to zero, meaning they hold the lowest priority in selector matching. This makes it extremely easy for you to override these values. 216 | > 217 | > Because this plugin sets both `position` and `view-transition-name` with zero specificity, you can override both of these styles with ease without having to worry about using `!` (`!important`) on your overriding styles. making them easy to overwrite with other values, if you choose. 218 | > 219 | > As a rule of thumb, anchored elements must use absolute or fixed positioning. This plugin defaults to `absolute` positioning, but you can add `fixed` to use fixed positioning instead for any anchored element. 220 | > 221 | > Because of the way the `view-transition-name` value is encoded, it really shouldn't conflict with any of your other styles, but if you wish to opt of that being applied as well, you can simple add `[view-transition-name:none]` to your classes for the anchored element (alongside the `anchored` utility classes). 222 | 223 | ## Supported Utilities 224 | 225 | * ### `anchor/{name}` 226 | 227 | Sets `anchor-name` 228 | 229 | * ### `anchored/{name}` 230 | 231 | Sets: `position-anchor` 232 | 233 | * ### `anchored-{position}` 234 | 235 | Sets `position-area`. Examples: 236 | - `anchored-top-center` → `top center` 237 | - `anchored-bottom-span-left` → `bottom span-left` 238 | - `anchored-right` → `right` 239 | 240 | * ### `{top|right|bottom|left|inset}-anchor-{side}-{offset}/{name?}` 241 | 242 | Generates explicit directional positioning using `anchor()`: 243 | 244 | ```html 245 |
246 | ``` 247 | 248 | Results in: 249 | 250 | ```css 251 | top: anchor(bottom); 252 | ``` 253 | 254 | With offset support: 255 | 256 | ```html 257 |
258 | ``` 259 | 260 | ```css 261 | top: calc(anchor(bottom) + 1rem); 262 | ``` 263 | 264 | * ### `{w|h}-anchor{-size?}/{name?}]` 265 | 266 | Sets size utilities using `anchor-size()`: 267 | 268 | - **Omitting the anchor size (i.e. default size/dimension)** 269 | 270 | `w-anchor` → `width: anchor-size(width)` 271 | 272 | If a size is omitted, the size value is set to the same size on the linked anchor element for the property being set. 273 | 274 | For example, (`w-anchor`) would set `width: anchor-size()`, which is equivalent to `width: anchor-size(width)`, setting the width of the anchored element equal to the width of its linked anchor. 275 | 276 | For further reading: [`anchor-size#anchor-size` (MDN)](https://developer.mozilla.org/en-US/docs/Web/CSS/anchor-size#anchor-size) 277 | - **Declaring the anchor-size (width/height/etc.) to use as a length** 278 | 279 | `w-anchor-height` → `width: anchor-size(height)` 280 | 281 | This example sets the anchored element's width to the height of its linked anchor element. This is useful when you want to create elements with dimensions based on different aspects of their anchor elements. 282 | - **Setting/omitting the anchor name** 283 | 284 | * `w-anchor` → `width: anchor-size(width)` 285 | * `w-anchor/--name` → `width: anchor-size(--name width)` 286 | * `w-anchor/name` → `width: anchor-size(--tw-anchor_name width)` 287 | 288 | Specifying an anchor name on an anchor size utility is entirely optional when setting sizes relevant to an anchored element's linked anchor element. 289 | 290 | However, it only defines which anchor the element's property values should be set relative to. 291 | 292 | For further reading: [`anchor-size#anchor-size` (MDN)](https://developer.mozilla.org/en-US/docs/Web/CSS/anchor-size#anchor-size) 293 | 294 | * ### `anchored-visible-*` (position-visibility) 295 | 296 | Controls when the anchored element is visible. 297 | - `anchored-visible-always`: Always visible (default behavior if property isn't set). 298 | - `anchored-visible-anchor`: Visible only when the anchor is at least partially visible. 299 | - `anchored-visible-no-overflow`: Visible only when the anchored element does not overflow the viewport. 300 | - Arbitrary values: `anchored-visible-[custom-value]` 301 | 302 | * ### `try-order-*` (position-try-order) 303 | 304 | Sets the order for trying fallback positions. 305 | - `try-order-normal`: Tries fallbacks in the order they are defined (default). 306 | - `try-order-w`: Prioritizes fallbacks that maximize width within the viewport. 307 | - `try-order-h`: Prioritizes fallbacks that maximize height within the viewport. 308 | - Arbitrary values: `try-order-[custom-value]` 309 | 310 | * ### `try-*` (position-try-fallbacks) 311 | 312 | Defines the fallback positions to attempt. 313 | - `try-none`: No fallbacks. 314 | - `try-flip-all`: Flips horizontally, vertically, and diagonally (`flip-block, flip-inline, flip-block flip-inline`). 315 | - `try-flip-x`: Flips horizontally (`flip-inline`). 316 | - `try-flip-y`: Flips vertically (`flip-block`). 317 | - `try-flip-s`: Flips based on writing mode start direction (`flip-start`). 318 | - Position area values: `try-top`, `try-bottom-left`, `try-left-span-top`, etc. (maps to the corresponding `position-area` value, e.g., `try-top` → `top`). 319 | - Arbitrary values: `try-[custom-value]` 320 | 321 | ## View Transition API Integration 322 | 323 | Every `anchored/{name}` class includes a `view-transition-name`, making your anchored elements animatable with `document.startViewTransition()`: 324 | 325 | ```js 326 | document.startViewTransition(() => { 327 | el.classList.remove('anchored-top-left') 328 | el.classList.add('anchored-bottom-right') 329 | }) 330 | ``` 331 | 332 | This animates position shifts smoothly using the browser-native View Transitions API. 333 | 334 | ## Why use Anchors for Tailwind CSS? 🤔 335 | 336 | - Declarative anchor positioning with Tailwind utility syntax 337 | - Full support for native anchor-related CSS properties and functions 338 | - Easy offset control via `calc(anchor(...) + theme spacing)` _(under the hood)_ 339 | - Built-in support for View Transitions 340 | - Fully JIT-compatible, no runtime required 341 | 342 | ## Additional Resources 📚 343 | 344 | - MDN: [Guide: Using CSS anchor positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning/Using) | [anchor-size()](https://developer.mozilla.org/en-US/docs/Web/CSS/anchor-size) | [anchor()](https://developer.mozilla.org/en-US/docs/Web/CSS/anchor) | [anchor-name](https://developer.mozilla.org/en-US/docs/Web/CSS/anchor-name) | [position-anchor](https://developer.mozilla.org/en-US/docs/Web/CSS/position-anchor) | [position-area](https://developer.mozilla.org/en-US/docs/Web/CSS/position-area) | [position-visibility](https://developer.mozilla.org/en-US/docs/Web/CSS/position-visibility) | [position-try-order](https://developer.mozilla.org/en-US/docs/Web/CSS/position-try-order) | [position-try-fallbacks](https://developer.mozilla.org/en-US/docs/Web/CSS/position-try-fallbacks) 345 | - CSS Tricks: [CSS Anchor Positioning Guide](https://css-tricks.com/css-anchor-positioning-guide/) 346 | - Specification: [CSS Anchor Positioning](https://drafts.csswg.org/css-anchor-position-1/) 347 | - [The Anchor Tool](https://anchor-tool.com/) 👈 probably the best way to get a real fast introduction to the CSS Anchor Positioning API, how it works, and how helpful it can be ✨ 348 | - [@Una](https://github.com/una)! I've never met anyone more fired up about CSS Anchor Positioning than Una Kravets, so definitely check out some of the things she's posted about it. I'm not 100% certain, but I think she may have actually created the [The Anchor Tool](https://anchor-tool.com/) mentioned above. 349 | 350 | ## If you liked this plugin... 351 | 352 | Check out more by [@branmcconnell](https://github.com/brandonmcconnell): 353 | 354 | - [tailwindcss-signals](https://github.com/brandonmcconnell/tailwindcss-signals): React to parent or ancestor state 355 | - [tailwindcss-members](https://github.com/brandonmcconnell/tailwindcss-members): Style based on child/descendant state 356 | - [tailwindcss-mixins](https://github.com/brandonmcconnell/tailwindcss-mixins): Reusable, aliased groups of utilities 357 | - [tailwindcss-selector-patterns](https://github.com/brandonmcconnell/tailwindcss-selector-patterns): Dynamic selector composition 358 | - [tailwindcss-directional-shadows](https://github.com/brandonmcconnell/tailwindcss-directional-shadows): Shadows with directional awareness 359 | - [tailwindcss-default-shades](https://github.com/brandonmcconnell/tailwindcss-default-shades): Simpler default color utility names 360 | - [tailwindcss-js](https://github.com/brandonmcconnell/tailwindcss-js): Effortless script injection 361 | - [tailwindcss-multi](https://github.com/brandonmcconnell/tailwindcss-multi): Group utility declarations under variants 362 | - [tailwind-lerp-colors](https://github.com/brandonmcconnell/tailwind-lerp-colors): Flexible color interpolation and palette tooling -------------------------------------------------------------------------------- /feature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toolwind/anchors/c46a9d8961caaf68a40430fefd9f5ade5628f933/feature.png -------------------------------------------------------------------------------- /index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin "./dist/index.js" -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import type { PluginCreator, PluginAPI } from 'tailwindcss/plugin'; 2 | import { 3 | createToggles, 4 | encoding, 5 | normalizeAnchorName, 6 | positionAreaValues, 7 | } from './utils.js'; 8 | export { encoding } from './utils.js'; 9 | 10 | const generateViewTransitionId = (str: string) => `--tw-anchor-view-transition-${encoding.encode(str)}` 11 | 12 | const anchors = ((api: PluginAPI) => { 13 | const { addBase, addUtilities, matchUtilities, theme } = api; 14 | 15 | // reset the styles of anchored popovers to fix problematic UA default styles 16 | const [popoverStyles, _, popoverToggles] = createToggles([ 17 | ['inset', 'auto', '0px'], 18 | ['background-color', 'transparent', 'canvas'], 19 | ['color', 'inherit', 'canvastext'], 20 | ['margin', '0px', 'auto'], 21 | ]); 22 | 23 | addBase({ '[popover]': popoverStyles }); 24 | 25 | // detect v4 by checking for the absence of the postcss argument 26 | const isV4 = !('postcss' in api); 27 | 28 | // anchor utilities (anchor-name) 29 | matchUtilities( 30 | { 31 | anchor: (_, { modifier }) => { 32 | const styles: Record = {}; 33 | if (modifier) { 34 | const anchorName = normalizeAnchorName(modifier, isV4); 35 | if (anchorName) { 36 | styles['anchor-name'] = anchorName; 37 | } 38 | } 39 | return styles; 40 | }, 41 | }, 42 | { 43 | values: { 44 | DEFAULT: '', 45 | }, 46 | modifiers: 'any', 47 | }, 48 | ); 49 | // anchor-scope (anchor-scope) 50 | matchUtilities( 51 | { 52 | 'anchor-scope': (_, { modifier }) => { 53 | const styles: Record = {}; 54 | if (modifier) { 55 | const anchorName = normalizeAnchorName(modifier, isV4); 56 | if (anchorName) { 57 | styles['anchor-scope'] = anchorName; 58 | } 59 | } 60 | return styles; 61 | }, 62 | }, 63 | { 64 | values: { 65 | DEFAULT: '', 66 | }, 67 | modifiers: 'any', 68 | }, 69 | ); 70 | // anchored utility (position-area and/or position-anchor) 71 | matchUtilities( 72 | { 73 | anchored: (value, { modifier }) => { 74 | if (!value && !modifier) return {}; 75 | const viewTransitionName = modifier && generateViewTransitionId(modifier); 76 | const anchorName = modifier && normalizeAnchorName(modifier, isV4); 77 | 78 | return { 79 | ...(value && { 'position-area': value }), 80 | ...(anchorName && { 81 | 'position-anchor': anchorName, 82 | '&:where(&)': { 83 | position: 'absolute', 84 | ...(viewTransitionName && { 'view-transition-name': viewTransitionName }), 85 | ...popoverToggles.on, 86 | 87 | /** TODO: ask community what they think about turning this on by default 88 | * and having to opt out when you don't want it, or leaving it off by 89 | * default and having to opt in when you do want it */ 90 | // 'position-try-fallbacks': 'flip-block, flip-inline, flip-block flip-inline', 91 | }, 92 | }), 93 | } 94 | }, 95 | }, 96 | { 97 | values: { 98 | DEFAULT: '', // will be '' if only a name is being set 99 | ...positionAreaValues, 100 | }, 101 | modifiers: 'any', 102 | }, 103 | ); 104 | // anchor() utilities 105 | ;([ 106 | ['top', theme('inset')], 107 | ['right', theme('inset')], 108 | ['bottom', theme('inset')], 109 | ['left', theme('inset')], 110 | ['inset', theme('inset')], 111 | ] as const).forEach(([property, themeValues]) => { 112 | ;['top', 'right', 'bottom', 'left', 'start', 'end', 'self-start', 'self-end', 'center'].forEach( 113 | (anchorSide) => { 114 | matchUtilities( 115 | { 116 | [`${property}-anchor-${anchorSide}`]: (offset, { modifier }) => { 117 | const anchorRef = modifier ? `${normalizeAnchorName(modifier, isV4)} ` : '' 118 | const anchorFnExpr = `anchor(${anchorRef}${anchorSide})` 119 | const value = offset ? `calc(${anchorFnExpr} + ${offset})` : anchorFnExpr 120 | return { 121 | [property]: value, 122 | ...popoverToggles.on, 123 | } 124 | }, 125 | }, 126 | { 127 | values: { 128 | DEFAULT: '', 129 | ...themeValues, 130 | }, 131 | supportsNegativeValues: true, 132 | modifiers: 'any', 133 | }, 134 | ) 135 | }, 136 | ) 137 | }) 138 | // anchor-size() utilities 139 | ;([ 140 | ['w', 'width', theme('width')], 141 | ['h', 'height', theme('height')], 142 | ['min-w', 'min-width', theme('minWidth')], 143 | ['min-h', 'min-height', theme('minHeight')], 144 | ['max-w', 'max-width', theme('maxWidth')], 145 | ['max-h', 'max-height', theme('maxHeight')], 146 | ] as const).forEach(([propertyAbbr, property, themeValues]) => { 147 | ;['', 'width', 'height', 'block', 'inline', 'self-block', 'self-inline'].forEach( 148 | (anchorSize) => { 149 | const anchorSizeUtilitySuffix = anchorSize ? `-${anchorSize}` : anchorSize 150 | matchUtilities( 151 | { 152 | [`${propertyAbbr}-anchor${anchorSizeUtilitySuffix}`]: (offset, { modifier }) => { 153 | const anchorRef = modifier ? `${normalizeAnchorName(modifier, isV4)} ` : '' 154 | const anchorFnExpr = `anchor-size(${anchorRef}${anchorSize})` 155 | const value = offset ? `calc(${anchorFnExpr} + ${offset})` : anchorFnExpr 156 | return { 157 | [property]: value, 158 | } 159 | }, 160 | }, 161 | { 162 | values: { 163 | DEFAULT: '', 164 | ...themeValues, 165 | }, 166 | supportsNegativeValues: true, 167 | modifiers: 'any', 168 | }, 169 | ) 170 | }, 171 | ) 172 | }) 173 | // anchor-center utilities 174 | ;([ 175 | ['justify-self', 'justify-self'], 176 | ['self', 'align-self'], 177 | ['justify-items', 'justify-items'], 178 | ['items', 'align-items'], 179 | ['place-items', 'place-items'], 180 | ['place-self', 'place-self'], 181 | ] as const).forEach(([propertyAbbr, property]) => { 182 | addUtilities({ 183 | [`.${propertyAbbr}-anchor`]: { 184 | [property]: 'anchor-center', 185 | ...popoverToggles.on, 186 | } 187 | }) 188 | }) 189 | // position-visibility utilities 190 | matchUtilities( 191 | { 192 | 'anchored-visible': (value) => ({ 193 | 'position-visibility': value, 194 | ...popoverToggles.on, 195 | }), 196 | }, 197 | { 198 | values: { 199 | always: 'always', 200 | anchor: 'anchors-visible', 201 | 'no-overflow': 'no-overflow', 202 | // 'valid': 'anchors-valid', // ⚠️ this is not supported anywhere yet 203 | }, 204 | }, 205 | ) 206 | // position-try-order utilities 207 | matchUtilities( 208 | { 209 | 'try-order': (value) => ({ 210 | 'position-try-order': value, 211 | ...popoverToggles.on, 212 | }), 213 | }, 214 | { 215 | values: { 216 | normal: 'normal', 217 | w: 'most-width', 218 | h: 'most-height', 219 | }, 220 | }, 221 | ) 222 | // position-try-fallbacks utilities 223 | matchUtilities( 224 | { 225 | 'try': (value) => ({ 226 | 'position-try-fallbacks': value, 227 | ...popoverToggles.on, 228 | }), 229 | }, 230 | { 231 | values: { 232 | none: 'none', 233 | 'flip-all': 'flip-block, flip-inline, flip-block flip-inline', 234 | 'flip-x': 'flip-inline', 235 | 'flip-y': 'flip-block', 236 | 'flip-s': 'flip-start', 237 | ...positionAreaValues, 238 | }, 239 | }, 240 | ) 241 | }) satisfies PluginCreator; 242 | 243 | // Cast to any to resolve d.ts generation issue 244 | export default anchors as any; 245 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toolwind/anchors/c46a9d8961caaf68a40430fefd9f5ade5628f933/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@toolwind/anchors", 3 | "version": "1.0.10", 4 | "description": "Anchors for Tailwind CSS provides a simple API for working with CSS anchor positioning, enabling flexible, declarative positioning relative to custom anchors.", 5 | "type": "module", 6 | "source": "index.ts", 7 | "exports": { 8 | ".": { 9 | "style": "./index.css", 10 | "types": "./dist/index.d.ts", 11 | "require": "./dist/index.cjs", 12 | "import": "./dist/index.js", 13 | "default": "./dist/index.js" 14 | }, 15 | "./package.json": "./package.json", 16 | "./index.css": "./index.css", 17 | "./index": "./dist/index.js" 18 | }, 19 | "style": "index.css", 20 | "types": "./dist/index.d.ts", 21 | "main": "./dist/index.cjs", 22 | "module": "./dist/index.js", 23 | "unpkg": "./dist/index.umd.js", 24 | "files": [ 25 | "dist", 26 | "index.css", 27 | "LICENSE", 28 | "README.md", 29 | "patches" 30 | ], 31 | "scripts": { 32 | "test": "echo \"Error: no test specified\" && exit 1", 33 | "build": "tsdown index.ts --dts --format cjs,esm", 34 | "dev": "tsdown index.ts --dts --watch --format cjs,esm", 35 | "prepublishOnly": "npm run build", 36 | "postinstall": "node -e \"if(require('fs').existsSync('.git')){require('child_process').execSync('patch-package', {stdio:'inherit'})} else {console.log('Skipping patch-package for @toolwind/anchors: not in a git repository.')}\"" 37 | }, 38 | "publishConfig": { 39 | "access": "public" 40 | }, 41 | "repository": { 42 | "type": "git", 43 | "url": "git+https://github.com/toolwind/anchors.git" 44 | }, 45 | "keywords": [ 46 | "css", 47 | "utility-classes", 48 | "anchor", 49 | "anchors", 50 | "anchor positioning", 51 | "anchor-positioning", 52 | "plugin", 53 | "plugins", 54 | "tailwind", 55 | "tailwindcss" 56 | ], 57 | "author": "Brandon McConnell", 58 | "license": "MIT", 59 | "bugs": { 60 | "url": "https://github.com/toolwind/anchors/issues" 61 | }, 62 | "homepage": "https://github.com/toolwind/anchors#readme", 63 | "devDependencies": { 64 | "@types/node": "^22.15.3", 65 | "@typescript-eslint/eslint-plugin": "^8.31.1", 66 | "@typescript-eslint/parser": "^8.31.1", 67 | "eslint": "^9.25.1", 68 | "eslint-config-prettier": "^10.1.2", 69 | "eslint-plugin-prettier": "^5.2.6", 70 | "npm-run-all": "^4.1.5", 71 | "patch-package": "^8.0.0", 72 | "tailwindcss": "^4.1.5", 73 | "tailwindcss-v4": "https://gitpkg.vercel.app/tailwindlabs/tailwindcss/packages/tailwindcss?v4.1.5", 74 | "tsdown": "^0.10.1", 75 | "typescript": "^5.8.3" 76 | }, 77 | "peerDependencies": { 78 | "tailwindcss": ">=3.0.0 || >=4.0.0" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /patches/tailwindcss-v4+4.1.5.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/tailwindcss-v4/src/candidate.ts b/node_modules/tailwindcss-v4/src/candidate.ts 2 | index 27a6b62..270f9cb 100644 3 | --- a/node_modules/tailwindcss-v4/src/candidate.ts 4 | +++ b/node_modules/tailwindcss-v4/src/candidate.ts 5 | @@ -503,7 +503,7 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter 6 | } 7 | } 8 | 9 | -function parseModifier(modifier: string): CandidateModifier | null { 10 | +export function parseModifier(modifier: string): CandidateModifier | null { 11 | if (modifier[0] === '[' && modifier[modifier.length - 1] === ']') { 12 | let arbitraryValue = decodeArbitraryValue(modifier.slice(1, -1)) 13 | 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "skipLibCheck": true, 5 | "target": "es2022", 6 | "allowJs": true, 7 | "resolveJsonModule": true, 8 | "moduleDetection": "force", 9 | "isolatedModules": true, 10 | "strict": true, 11 | "noUncheckedIndexedAccess": true, 12 | "module": "NodeNext", 13 | "moduleResolution": "nodenext", 14 | "outDir": "dist", 15 | "sourceMap": true, 16 | "declaration": true, 17 | "lib": ["es2022"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /utils.ts: -------------------------------------------------------------------------------- 1 | import { parseModifier as parseModifierV4 } from './node_modules/tailwindcss-v4/src/candidate.js'; 2 | 3 | const prefixAnchorName = (name: string) => `--tw-anchor_${name}`; 4 | 5 | const reservedNames = [ 6 | // global values 7 | 'inherit', 8 | 'initial', 9 | 'revert', 10 | 'revert-layer', 11 | 'unset', 12 | // none is a reserved value for `anchor-name` 13 | 'none', 14 | ]; 15 | 16 | const normalizeAnchorNameCore = (modifier: string | undefined) => { 17 | // trim in case of any leading/trailing whitespace 18 | modifier = modifier?.trim(); 19 | if (!modifier) return null; 20 | 21 | /** current bug: v4 parses variable shorthand syntax in modifiers as 22 | * standard arbitrary values and replaces underscores with spaces, 23 | * so this undoes that as a stop-gap-solution */ 24 | modifier = modifier.replace(/ /g, '_'); 25 | 26 | if ( 27 | reservedNames.some(name => modifier === name) || 28 | modifier.startsWith('--') || 29 | modifier.startsWith('var(') 30 | ) { 31 | return modifier; 32 | } 33 | return prefixAnchorName(modifier); 34 | } 35 | 36 | export const normalizeAnchorName = (modifier: string, isV4: boolean) => { 37 | if (!modifier) return null; 38 | if (isV4) return normalizeAnchorNameCore(modifier); 39 | // Don't allow v3 to use v4 variable shorthand syntax: `(--name)` -> `var(--name)` 40 | if (modifier.startsWith('(') && modifier.endsWith(')')) { 41 | throw new Error(`This variable shorthand syntax is only supported in Tailwind CSS v4.0 and above: ${modifier}. In v3.x, you must use [${modifier.slice(1,-1)}].`); 42 | } 43 | // Explicitly support v3 variable shorthand syntax: `[--name]` -> `var(--name)` 44 | if (modifier.startsWith('[--')) { 45 | return `var(${modifier.slice(1, -1)})`; 46 | } 47 | return normalizeAnchorNameCore(parseModifierV4(modifier)?.value); 48 | }; 49 | 50 | // encode & decode functions to normalize anchor names for use in view-transition-name 51 | export const encoding = { 52 | encode: (str: string) => { 53 | let encoded = ''; 54 | for (const char of str) { 55 | encoded += char.charCodeAt(0).toString(36); 56 | } 57 | return encoded; 58 | }, 59 | decode: (encodedStr: string) => { 60 | const decodedChars = []; 61 | let charCode = ''; 62 | for (const char of encodedStr) { 63 | charCode += char; 64 | const code = Number.parseInt(charCode, 36); 65 | if (!isNaN(code) && code >= 32 && code <= 126) { 66 | decodedChars.push(String.fromCharCode(code)); 67 | charCode = ''; 68 | } 69 | } 70 | return decodedChars.join(''); 71 | } 72 | }; 73 | 74 | export const generateRandomString = (length = 10) => { 75 | const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 76 | let result = ''; 77 | for (let i = 0; i < length; i++) { 78 | const randomIndex = Math.floor(Math.random() * charset.length); 79 | result += charset[randomIndex]; 80 | } 81 | return result; 82 | } 83 | 84 | type ToggleReturn = ReturnType; 85 | type TogglesReturn = [ 86 | cssStyles: ToggleReturn[0], 87 | toggles: Record, 88 | groupedToggles: { 89 | on: ToggleReturn[1]['on']; 90 | off: ToggleReturn[1]['off']; 91 | }, 92 | ]; 93 | 94 | export const createToggle = (property: string, on: string, off: string) => { 95 | const varName = `--toolwind-toggle-${generateRandomString()}`; 96 | return [ 97 | { 98 | [property]: `var(${varName}, ${off})`, 99 | }, 100 | { 101 | on: { [varName]: on, }, 102 | off: { [varName]: off, }, 103 | }, 104 | ] as const; 105 | } 106 | 107 | export const createToggles = ( 108 | togglesData: Parameters[], 109 | ): TogglesReturn => { 110 | return togglesData.reduce( 111 | (acc: TogglesReturn, [property, on, off]) => { 112 | const [cssStyles, toggle] = createToggle(property, on, off); 113 | return [ 114 | { ...acc[0], ...cssStyles }, 115 | { ...acc[1], [property]: toggle }, 116 | { ...acc[2], on: { ...acc[2].on, ...toggle.on }, off: { ...acc[2].off, ...toggle.off } }, 117 | ]; 118 | }, 119 | [{}, {}, { on: {}, off: {} }], 120 | ); 121 | }; 122 | 123 | // position area values for use in anchored and position-try-fallbacks utilities 124 | export const positionAreaValues = Object.fromEntries( 125 | [ 126 | 'top center', 127 | 'top span-left', 128 | 'top span-right', 129 | 'top', 130 | 'left center', 131 | 'left span-top', 132 | 'left span-bottom', 133 | 'left', 134 | 'right center', 135 | 'right span-top', 136 | 'right span-bottom', 137 | 'right', 138 | 'bottom center', 139 | 'bottom span-left', 140 | 'bottom span-right', 141 | 'bottom', 142 | 'top left', 143 | 'top right', 144 | 'bottom left', 145 | 'bottom right', 146 | ].map((value) => [value.replace(/ /g, '-'), value]) 147 | ); 148 | 149 | --------------------------------------------------------------------------------