concertina 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Ward
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.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/ryandward/concertina/main/concertina.svg" width="140" alt="concertina" />
3
+ </p>
4
+
5
+ <h1 align="center">concertina</h1>
6
+
7
+ <p align="center">
8
+ React hook for scroll-pinned <a href="https://www.radix-ui.com/primitives/docs/components/accordion">Radix Accordion</a> panels. Zero runtime dependencies.
9
+ </p>
10
+
11
+ ## The problem
12
+
13
+ Radix Accordion in a scrollable container breaks in several ways at once. Switching between items plays both a close and open animation, so the scroll position jumps. Calling `scrollIntoView` to fix it cascades to the viewport on mobile and yanks the whole page. Using `flushSync` with inline styles to work around that fights Radix re-renders. Layout measurement happens before animations finish, so scroll targets are wrong. And suppressing animations for the switch has to be temporary or you lose them entirely.
14
+
15
+ These five issues interact. `concertina` solves them together.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm install concertina
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```tsx
26
+ import { useConcertina } from "concertina";
27
+ import "concertina/styles.css";
28
+ import * as Accordion from "@radix-ui/react-accordion";
29
+
30
+ function MyAccordion({ items }) {
31
+ const { rootProps, getItemRef } = useConcertina();
32
+
33
+ return (
34
+ <Accordion.Root type="single" collapsible {...rootProps}>
35
+ {items.map((item) => (
36
+ <Accordion.Item
37
+ key={item.id}
38
+ value={item.id}
39
+ ref={getItemRef(item.id)}
40
+ >
41
+ <Accordion.Header>
42
+ <Accordion.Trigger>{item.title}</Accordion.Trigger>
43
+ </Accordion.Header>
44
+ <Accordion.Content className="concertina-content">
45
+ {item.content}
46
+ </Accordion.Content>
47
+ </Accordion.Item>
48
+ ))}
49
+ </Accordion.Root>
50
+ );
51
+ }
52
+ ```
53
+
54
+ ## How it works
55
+
56
+ The hook tracks which item is open via `value`/`onValueChange`. When switching from one item to another, it sets a `data-switching` attribute on the root. CSS rules keyed to that attribute skip the close/open animations so layout settles in one frame. A `useLayoutEffect` then adjusts the scroll container's `scrollTop` to pin the new item to the top (never `scrollIntoView`, which cascades). After paint, a `useEffect` clears the flag so the next interaction animates normally.
57
+
58
+ ## API
59
+
60
+ ### `useConcertina()`
61
+
62
+ | Property | Type | Description |
63
+ |---|---|---|
64
+ | `value` | `string` | Currently expanded item, empty string when collapsed |
65
+ | `onValueChange` | `(value: string) => void` | Change handler with switching logic built in |
66
+ | `switching` | `boolean` | `true` during a switch between items |
67
+ | `rootProps` | `object` | Spread onto `Accordion.Root` (includes `value`, `onValueChange`, `data-switching`) |
68
+ | `getItemRef` | `(id: string) => RefCallback` | Ref callback for each `Accordion.Item` |
69
+
70
+ ### `pinToScrollTop(el)`
71
+
72
+ Also exported standalone. Scrolls `el` to the top of its nearest `overflow-y: auto|scroll` ancestor without touching the viewport.
73
+
74
+ ## Styling
75
+
76
+ Import `concertina/styles.css` for the default expand/collapse animations. Override timing with CSS custom properties on `.concertina-content`:
77
+
78
+ ```css
79
+ .concertina-content {
80
+ --concertina-open-duration: 300ms;
81
+ --concertina-close-duration: 200ms;
82
+ }
83
+ ```
84
+
85
+ Put `.concertina-content` on your `Accordion.Content` elements. The `[data-switching]` suppression rules are handled automatically.
86
+
87
+ If items near the bottom of your scroll container can't reach the top, add bottom padding:
88
+
89
+ ```css
90
+ .my-scroll-container {
91
+ padding-bottom: 50vh;
92
+ }
93
+ ```
94
+
95
+ ## Requirements
96
+
97
+ - React >= 16.8
98
+ - `@radix-ui/react-accordion`
99
+
100
+ ## License
101
+
102
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ pinToScrollTop: () => pinToScrollTop,
24
+ useConcertina: () => useConcertina
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+
28
+ // src/use-concertina.ts
29
+ var import_react = require("react");
30
+
31
+ // src/pin-to-scroll-top.ts
32
+ function pinToScrollTop(el) {
33
+ if (!el) return;
34
+ let parent = el.parentElement;
35
+ while (parent) {
36
+ const { overflowY } = getComputedStyle(parent);
37
+ if (overflowY === "auto" || overflowY === "scroll") {
38
+ const box = parent.getBoundingClientRect();
39
+ const target = el.getBoundingClientRect();
40
+ parent.scrollTop += target.top - box.top;
41
+ return;
42
+ }
43
+ parent = parent.parentElement;
44
+ }
45
+ }
46
+
47
+ // src/use-concertina.ts
48
+ function useConcertina() {
49
+ const [value, setValue] = (0, import_react.useState)("");
50
+ const [switching, setSwitching] = (0, import_react.useState)(false);
51
+ const itemRefs = (0, import_react.useRef)({});
52
+ const onValueChange = (0, import_react.useCallback)(
53
+ (newValue) => {
54
+ if (!newValue) {
55
+ setSwitching(false);
56
+ setValue("");
57
+ return;
58
+ }
59
+ setSwitching(!!value && value !== newValue);
60
+ setValue(newValue);
61
+ },
62
+ [value]
63
+ );
64
+ (0, import_react.useLayoutEffect)(() => {
65
+ if (!value) return;
66
+ pinToScrollTop(itemRefs.current[value]);
67
+ }, [value]);
68
+ (0, import_react.useEffect)(() => {
69
+ if (switching) setSwitching(false);
70
+ }, [switching]);
71
+ const getItemRef = (0, import_react.useCallback)(
72
+ (id) => (el) => {
73
+ itemRefs.current[id] = el;
74
+ },
75
+ []
76
+ );
77
+ const rootProps = {
78
+ value,
79
+ onValueChange,
80
+ ...switching ? { "data-switching": true } : {}
81
+ };
82
+ return { value, onValueChange, switching, rootProps, getItemRef };
83
+ }
84
+ // Annotate the CommonJS export names for ESM import in node:
85
+ 0 && (module.exports = {
86
+ pinToScrollTop,
87
+ useConcertina
88
+ });
@@ -0,0 +1,39 @@
1
+ interface ConcertinaRootProps {
2
+ value: string;
3
+ onValueChange: (value: string) => void;
4
+ "data-switching"?: true;
5
+ }
6
+ interface UseConcertinaReturn {
7
+ /** Currently expanded item value, empty string when collapsed. */
8
+ value: string;
9
+ /** Change handler. Manages switching state automatically. */
10
+ onValueChange: (value: string) => void;
11
+ /** True during a switch between items (animations suppressed). */
12
+ switching: boolean;
13
+ /** Spread onto Accordion.Root. Includes value, onValueChange, data-switching. */
14
+ rootProps: ConcertinaRootProps;
15
+ /** Returns a ref callback for an Accordion.Item. Pass the item's value. */
16
+ getItemRef: (id: string) => (el: HTMLElement | null) => void;
17
+ }
18
+ /**
19
+ * React hook for scroll-pinned Radix Accordion panels.
20
+ *
21
+ * Handles five things:
22
+ * 1. Suppresses close/open animations when switching between items
23
+ * 2. Pins the newly opened item to the top of the scroll container
24
+ * 3. Uses scrollTop adjustment instead of scrollIntoView (no viewport cascade)
25
+ * 4. Coordinates React state batching so layout is final before scroll measurement
26
+ * 5. Clears the switching flag after paint so future animations work normally
27
+ */
28
+ declare function useConcertina(): UseConcertinaReturn;
29
+
30
+ /**
31
+ * Scroll `el` to the top of its nearest scrollable ancestor.
32
+ *
33
+ * Only adjusts one container's scrollTop. Never cascades to the
34
+ * viewport, which matters on mobile where scrollIntoView pulls
35
+ * the whole page.
36
+ */
37
+ declare function pinToScrollTop(el: HTMLElement | null): void;
38
+
39
+ export { type ConcertinaRootProps, type UseConcertinaReturn, pinToScrollTop, useConcertina };
@@ -0,0 +1,39 @@
1
+ interface ConcertinaRootProps {
2
+ value: string;
3
+ onValueChange: (value: string) => void;
4
+ "data-switching"?: true;
5
+ }
6
+ interface UseConcertinaReturn {
7
+ /** Currently expanded item value, empty string when collapsed. */
8
+ value: string;
9
+ /** Change handler. Manages switching state automatically. */
10
+ onValueChange: (value: string) => void;
11
+ /** True during a switch between items (animations suppressed). */
12
+ switching: boolean;
13
+ /** Spread onto Accordion.Root. Includes value, onValueChange, data-switching. */
14
+ rootProps: ConcertinaRootProps;
15
+ /** Returns a ref callback for an Accordion.Item. Pass the item's value. */
16
+ getItemRef: (id: string) => (el: HTMLElement | null) => void;
17
+ }
18
+ /**
19
+ * React hook for scroll-pinned Radix Accordion panels.
20
+ *
21
+ * Handles five things:
22
+ * 1. Suppresses close/open animations when switching between items
23
+ * 2. Pins the newly opened item to the top of the scroll container
24
+ * 3. Uses scrollTop adjustment instead of scrollIntoView (no viewport cascade)
25
+ * 4. Coordinates React state batching so layout is final before scroll measurement
26
+ * 5. Clears the switching flag after paint so future animations work normally
27
+ */
28
+ declare function useConcertina(): UseConcertinaReturn;
29
+
30
+ /**
31
+ * Scroll `el` to the top of its nearest scrollable ancestor.
32
+ *
33
+ * Only adjusts one container's scrollTop. Never cascades to the
34
+ * viewport, which matters on mobile where scrollIntoView pulls
35
+ * the whole page.
36
+ */
37
+ declare function pinToScrollTop(el: HTMLElement | null): void;
38
+
39
+ export { type ConcertinaRootProps, type UseConcertinaReturn, pinToScrollTop, useConcertina };
package/dist/index.js ADDED
@@ -0,0 +1,66 @@
1
+ // src/use-concertina.ts
2
+ import {
3
+ useState,
4
+ useCallback,
5
+ useRef,
6
+ useLayoutEffect,
7
+ useEffect
8
+ } from "react";
9
+
10
+ // src/pin-to-scroll-top.ts
11
+ function pinToScrollTop(el) {
12
+ if (!el) return;
13
+ let parent = el.parentElement;
14
+ while (parent) {
15
+ const { overflowY } = getComputedStyle(parent);
16
+ if (overflowY === "auto" || overflowY === "scroll") {
17
+ const box = parent.getBoundingClientRect();
18
+ const target = el.getBoundingClientRect();
19
+ parent.scrollTop += target.top - box.top;
20
+ return;
21
+ }
22
+ parent = parent.parentElement;
23
+ }
24
+ }
25
+
26
+ // src/use-concertina.ts
27
+ function useConcertina() {
28
+ const [value, setValue] = useState("");
29
+ const [switching, setSwitching] = useState(false);
30
+ const itemRefs = useRef({});
31
+ const onValueChange = useCallback(
32
+ (newValue) => {
33
+ if (!newValue) {
34
+ setSwitching(false);
35
+ setValue("");
36
+ return;
37
+ }
38
+ setSwitching(!!value && value !== newValue);
39
+ setValue(newValue);
40
+ },
41
+ [value]
42
+ );
43
+ useLayoutEffect(() => {
44
+ if (!value) return;
45
+ pinToScrollTop(itemRefs.current[value]);
46
+ }, [value]);
47
+ useEffect(() => {
48
+ if (switching) setSwitching(false);
49
+ }, [switching]);
50
+ const getItemRef = useCallback(
51
+ (id) => (el) => {
52
+ itemRefs.current[id] = el;
53
+ },
54
+ []
55
+ );
56
+ const rootProps = {
57
+ value,
58
+ onValueChange,
59
+ ...switching ? { "data-switching": true } : {}
60
+ };
61
+ return { value, onValueChange, switching, rootProps, getItemRef };
62
+ }
63
+ export {
64
+ pinToScrollTop,
65
+ useConcertina
66
+ };
@@ -0,0 +1,54 @@
1
+ /* concertina — Radix Accordion expand/collapse with scroll pinning support
2
+ *
3
+ * Add .concertina-content to your Accordion.Content elements.
4
+ * The [data-switching] rules are managed automatically by useConcertina().
5
+ */
6
+
7
+ .concertina-content {
8
+ --concertina-open-duration: 200ms;
9
+ --concertina-close-duration: 150ms;
10
+ overflow: hidden;
11
+ }
12
+
13
+ .concertina-content[data-state="open"] {
14
+ animation: concertina-open var(--concertina-open-duration) ease-out;
15
+ }
16
+
17
+ .concertina-content[data-state="closed"] {
18
+ animation: concertina-close var(--concertina-close-duration) ease-out forwards;
19
+ }
20
+
21
+ @keyframes concertina-open {
22
+ from {
23
+ height: 0;
24
+ opacity: 0;
25
+ }
26
+ to {
27
+ height: var(--radix-accordion-content-height);
28
+ opacity: 1;
29
+ }
30
+ }
31
+
32
+ @keyframes concertina-close {
33
+ from {
34
+ height: var(--radix-accordion-content-height);
35
+ opacity: 1;
36
+ }
37
+ to {
38
+ height: 0;
39
+ opacity: 0;
40
+ }
41
+ }
42
+
43
+ /* When switching between items, suppress all animations so the layout
44
+ is immediately in its final state for scroll pinning.
45
+ data-switching is set by useConcertina(), cleared after paint. */
46
+ [data-switching] .concertina-content[data-state="closed"] {
47
+ animation: none;
48
+ height: 0;
49
+ opacity: 0;
50
+ }
51
+
52
+ [data-switching] .concertina-content[data-state="open"] {
53
+ animation: none;
54
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "concertina",
3
+ "version": "0.1.0",
4
+ "description": "React hook for scroll-pinned Radix Accordion panels. Zero dependencies.",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/index.d.cts",
17
+ "default": "./dist/index.cjs"
18
+ }
19
+ },
20
+ "./styles.css": "./dist/styles.css"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "sideEffects": [
26
+ "./dist/styles.css"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsup && cp src/styles.css dist/styles.css",
30
+ "typecheck": "tsc --noEmit",
31
+ "prepublishOnly": "npm run build"
32
+ },
33
+ "peerDependencies": {
34
+ "react": ">=16.8.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/react": "^19.0.0",
38
+ "react": "^19.0.0",
39
+ "tsup": "^8.0.0",
40
+ "typescript": "^5.0.0"
41
+ },
42
+ "keywords": [
43
+ "radix",
44
+ "accordion",
45
+ "scroll",
46
+ "pin",
47
+ "react",
48
+ "hook"
49
+ ],
50
+ "license": "MIT",
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "https://github.com/ryandward/concertina"
54
+ },
55
+ "author": "Ryan Ward"
56
+ }