├── .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 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 |
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 |
--------------------------------------------------------------------------------