@yewdiel21/motion-kit 0.9.1 → 0.10.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.
Files changed (129) hide show
  1. package/README.md +93 -83
  2. package/dist/auto/index.d.ts +22 -3
  3. package/dist/auto/index.js +41 -3
  4. package/dist/auto/index.js.map +1 -1
  5. package/dist/{chunk-BFC27F2G.js → chunk-IKQ5FODN.js} +2 -2
  6. package/dist/{chunk-JQMJQSXD.js → chunk-TFNRIZHU.js} +1614 -11
  7. package/dist/chunk-TFNRIZHU.js.map +1 -0
  8. package/dist/{chunk-XDWBVFHQ.js → chunk-XAKC2QY7.js} +1202 -3
  9. package/dist/chunk-XAKC2QY7.js.map +1 -0
  10. package/dist/css/components/calendar.css +159 -0
  11. package/dist/css/components/hover-card.css +14 -0
  12. package/dist/css/components/input-otp.css +10 -0
  13. package/dist/css/components/pagination.css +11 -0
  14. package/dist/css/components/quantity.css +16 -0
  15. package/dist/css/components/slot-picker.css +126 -0
  16. package/dist/css/components/studio-dock.css +35 -0
  17. package/dist/css/components/trailer.css +37 -0
  18. package/dist/css/controls.css +65 -0
  19. package/dist/elements/index.d.ts +72 -4
  20. package/dist/elements/index.js +170 -4
  21. package/dist/elements/index.js.map +1 -1
  22. package/dist/index.css +1017 -0
  23. package/dist/index.d.ts +3 -3
  24. package/dist/index.js +45 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/react/index.d.ts +105 -6
  27. package/dist/react/index.js +350 -3
  28. package/dist/react/index.js.map +1 -1
  29. package/dist/{reduced-B98f8LPs.d.ts → reduced-BmBzKH94.d.ts} +199 -2
  30. package/dist/{reveal-C0hCDcB_.d.ts → reveal-jsX-uwUz.d.ts} +211 -1
  31. package/dist/{store-pHiUvxP8.d.ts → store-DYb3tTsZ.d.ts} +1 -1
  32. package/package.json +1 -1
  33. package/registry.json +178 -104
  34. package/skills/motion-kit/SKILL.md +209 -189
  35. package/src/auto/auto-init.ts +38 -0
  36. package/src/components/calendar/README.md +82 -0
  37. package/src/components/calendar/calendar.css +158 -0
  38. package/src/components/calendar/element.ts +27 -0
  39. package/src/components/calendar/engine.ts +412 -0
  40. package/src/components/calendar/react.tsx +69 -0
  41. package/src/components/hover-card/README.md +16 -0
  42. package/src/components/hover-card/element.ts +8 -0
  43. package/src/components/hover-card/engine.ts +69 -0
  44. package/src/components/hover-card/hover-card.css +13 -0
  45. package/src/components/hover-card/react.tsx +19 -0
  46. package/src/components/input-otp/README.md +18 -0
  47. package/src/components/input-otp/element.ts +14 -0
  48. package/src/components/input-otp/engine.ts +106 -0
  49. package/src/components/input-otp/input-otp.css +9 -0
  50. package/src/components/input-otp/react.tsx +21 -0
  51. package/src/components/pagination/README.md +17 -0
  52. package/src/components/pagination/element.ts +14 -0
  53. package/src/components/pagination/engine.ts +102 -0
  54. package/src/components/pagination/pagination.css +10 -0
  55. package/src/components/pagination/react.tsx +22 -0
  56. package/src/components/quantity/README.md +20 -0
  57. package/src/components/quantity/element.ts +14 -0
  58. package/src/components/quantity/engine.ts +85 -0
  59. package/src/components/quantity/quantity.css +15 -0
  60. package/src/components/quantity/react.tsx +21 -0
  61. package/src/components/slot-picker/README.md +68 -0
  62. package/src/components/slot-picker/element.ts +27 -0
  63. package/src/components/slot-picker/engine.ts +412 -0
  64. package/src/components/slot-picker/react.tsx +76 -0
  65. package/src/components/slot-picker/slot-picker.css +125 -0
  66. package/src/components/studio-dock/README.md +44 -0
  67. package/src/components/studio-dock/element.ts +11 -0
  68. package/src/components/studio-dock/engine.ts +212 -0
  69. package/src/components/studio-dock/react.tsx +23 -0
  70. package/src/components/studio-dock/studio-dock.css +34 -0
  71. package/src/components/trailer/README.md +28 -0
  72. package/src/components/trailer/element.ts +13 -0
  73. package/src/components/trailer/engine.ts +189 -0
  74. package/src/components/trailer/react.tsx +16 -0
  75. package/src/components/trailer/trailer.css +36 -0
  76. package/src/core/dates.ts +98 -0
  77. package/src/css/controls/README.md +14 -0
  78. package/src/css/controls/status.css +63 -0
  79. package/src/elements/index.ts +24 -0
  80. package/src/index.ts +25 -0
  81. package/src/react/index.ts +21 -0
  82. package/src/react/use-action-button.ts +6 -0
  83. package/src/react/use-editorial-reveal.ts +7 -0
  84. package/src/react/use-film-hero.ts +6 -0
  85. package/src/react/use-font-cycle.ts +7 -0
  86. package/src/react/use-image-zoom.ts +5 -0
  87. package/src/react/use-joined-cta.ts +5 -0
  88. package/src/react/use-local-clock.ts +5 -0
  89. package/src/react/use-scroll-dock.ts +5 -0
  90. package/src/react/use-sticker.ts +5 -0
  91. package/src/react/use-studio-dock.ts +8 -0
  92. package/src/react/use-thumbnail-nav.ts +7 -0
  93. package/src/react/use-trailer.ts +6 -0
  94. package/src/react/use-weight-hover.ts +5 -0
  95. package/src/recipes/action-button/README.md +31 -0
  96. package/src/recipes/action-button/action-button.css +113 -0
  97. package/src/recipes/action-button/create-action-button.ts +56 -0
  98. package/src/recipes/editorial-reveal/README.md +35 -0
  99. package/src/recipes/editorial-reveal/create-editorial-reveal.ts +91 -0
  100. package/src/recipes/editorial-reveal/editorial-reveal.css +17 -0
  101. package/src/recipes/film-hero/README.md +23 -0
  102. package/src/recipes/film-hero/create-film-hero.ts +65 -0
  103. package/src/recipes/film-hero/film-hero.css +29 -0
  104. package/src/recipes/font-cycle/README.md +11 -0
  105. package/src/recipes/font-cycle/create-font-cycle.ts +126 -0
  106. package/src/recipes/font-cycle/font-cycle.css +36 -0
  107. package/src/recipes/image-zoom/README.md +16 -0
  108. package/src/recipes/image-zoom/create-image-zoom.ts +27 -0
  109. package/src/recipes/image-zoom/image-zoom.css +7 -0
  110. package/src/recipes/joined-cta/README.md +13 -0
  111. package/src/recipes/joined-cta/create-joined-cta.ts +22 -0
  112. package/src/recipes/joined-cta/joined-cta.css +17 -0
  113. package/src/recipes/local-clock/README.md +31 -0
  114. package/src/recipes/local-clock/create-local-clock.ts +61 -0
  115. package/src/recipes/scroll-dock/README.md +46 -0
  116. package/src/recipes/scroll-dock/create-scroll-dock.ts +72 -0
  117. package/src/recipes/scroll-dock/scroll-dock.css +70 -0
  118. package/src/recipes/sticker/README.md +59 -0
  119. package/src/recipes/sticker/create-sticker.ts +490 -0
  120. package/src/recipes/sticker/sticker.css +112 -0
  121. package/src/recipes/thumbnail-nav/README.md +16 -0
  122. package/src/recipes/thumbnail-nav/create-thumbnail-nav.ts +165 -0
  123. package/src/recipes/thumbnail-nav/thumbnail-nav.css +108 -0
  124. package/src/recipes/weight-hover/README.md +37 -0
  125. package/src/recipes/weight-hover/create-weight-hover.ts +136 -0
  126. package/src/recipes/weight-hover/weight-hover.css +7 -0
  127. package/dist/chunk-JQMJQSXD.js.map +0 -1
  128. package/dist/chunk-XDWBVFHQ.js.map +0 -1
  129. /package/dist/{chunk-BFC27F2G.js.map → chunk-IKQ5FODN.js.map} +0 -0
@@ -0,0 +1,16 @@
1
+ # image-zoom
2
+
3
+ Contrazoom inspirado en las categorías de [Bernice](https://bernicebakery.com/collections/cakes). Marco 1→1.1 e imagen 1.2→1 en 1 s con cubic-bezier(.28,.71,0,.98).
4
+
5
+ ```html
6
+ <a class="mk-image-zoom" data-mk="image-zoom" href="/collection">
7
+ <span data-mk-zoom-frame style="border-radius:50%;width:10rem;height:10rem">
8
+ <img src="/image.jpg" width="400" height="400" alt="Descripción" />
9
+ </span>
10
+ <strong>Colección</strong>
11
+ </a>
12
+ ```
13
+
14
+ `createImageZoom(root,{duration:1,frameScale:1.1,imageScale:1.2})`, `autoInit()`, `useImageZoom(ref,options,deps)`. API `open(boolean)`, `active`, `destroy()`.
15
+
16
+ Hover/focus revelan el mismo efecto. Root puede contener imagen, video o picture dentro de frame. Para marcado distinto usar `data-mk-zoom-image` en el elemento que escala (evitar marcar también su padre). Variables: `--mk-zoom-duration`, `--mk-zoom-frame-scale`, `--mk-zoom-image-scale`, `--mk-zoom-ease`. Sin transformaciones bajo movimiento reducido; las imágenes siguen accesibles.
@@ -0,0 +1,27 @@
1
+ import { createScope } from '../../core/scope';
2
+ import { inert, type Instance } from '../../core/types';
3
+
4
+ export interface ImageZoomOptions { duration?: number; frameScale?: number; imageScale?: number; }
5
+ export interface ImageZoomInstance extends Instance { open(active: boolean): void; readonly active: boolean; }
6
+
7
+ /** Counter-zoom: the clipped frame grows as the picture inside settles to 1×. */
8
+ export function createImageZoom(root: HTMLElement | null, options: ImageZoomOptions = {}): ImageZoomInstance {
9
+ if (!root) return inert({ open() {}, active: false });
10
+ const scope = createScope();
11
+ let active = false;
12
+ let pointer = false;
13
+ scope.attr(root, 'style', root.getAttribute('style'));
14
+ scope.attr(root, 'data-mk-ready', '');
15
+ const setVariable = (name: string, value: number | undefined, suffix = '') => {
16
+ if (Number.isFinite(value)) root.style.setProperty(name, `${Math.max(0, value!)}${suffix}`);
17
+ };
18
+ setVariable('--mk-zoom-duration', options.duration, 's');
19
+ setVariable('--mk-zoom-frame-scale', options.frameScale);
20
+ setVariable('--mk-zoom-image-scale', options.imageScale);
21
+ const set = (on: boolean) => { if (!scope.disposed) { active = on; scope.attr(root, 'data-mk-active', on ? '' : null); } };
22
+ scope.on(root, 'pointerenter', () => { pointer = true; set(true); });
23
+ scope.on(root, 'pointerleave', () => { pointer = false; if (!root.contains(document.activeElement)) set(false); });
24
+ scope.on(root, 'focusin', () => set(true));
25
+ scope.on(root, 'focusout', (event) => { if (!pointer && !root.contains((event as FocusEvent).relatedTarget as Node | null)) set(false); });
26
+ return { open: set, get active() { return active; }, destroy: () => scope.dispose() };
27
+ }
@@ -0,0 +1,7 @@
1
+ .mk-image-zoom { --mk-zoom-duration: 1s; --mk-zoom-frame-scale: 1.1; --mk-zoom-image-scale: 1.2; --mk-zoom-ease: cubic-bezier(.28,.71,0,.98); color: inherit; text-decoration: none; }
2
+ .mk-image-zoom [data-mk-zoom-frame] { display: block; overflow: hidden; border-radius: inherit; transition: transform var(--mk-zoom-duration) var(--mk-zoom-ease); }
3
+ .mk-image-zoom [data-mk-zoom-frame] > :is(img,video,picture), .mk-image-zoom [data-mk-zoom-image] { display: block; width: 100%; height: 100%; object-fit: cover; transform: scale(var(--mk-zoom-image-scale)); transition: transform var(--mk-zoom-duration) var(--mk-zoom-ease); }
4
+ .mk-image-zoom:is(:hover,:focus-within,[data-mk-active]) [data-mk-zoom-frame] { transform: scale(var(--mk-zoom-frame-scale)); }
5
+ .mk-image-zoom:is(:hover,:focus-within,[data-mk-active]) [data-mk-zoom-frame] > :is(img,video,picture), .mk-image-zoom:is(:hover,:focus-within,[data-mk-active]) [data-mk-zoom-image] { transform: scale(1); }
6
+ .mk-image-zoom:focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 5px; }
7
+ @media (prefers-reduced-motion: reduce) { .mk-image-zoom [data-mk-zoom-frame], .mk-image-zoom [data-mk-zoom-frame] > :is(img,video,picture), .mk-image-zoom [data-mk-zoom-image] { transform: none !important; transition: none; } }
@@ -0,0 +1,13 @@
1
+ # joined-cta
2
+
3
+ CTA inspirado en [Silvia Sguotti](https://silviasguotti.design/): tres cápsulas que se desplazan, giran y se unen. El CSS público declara .5 s ease-in-out.
4
+
5
+ ```html
6
+ <button type="button" class="mk-joined-cta" data-mk="joined-cta" aria-label="Keep in touch">
7
+ <span>KEEP</span><span>IN</span><span>TOUCH</span>
8
+ </button>
9
+ ```
10
+
11
+ `createJoinedCta(root,{duration:.5})`, `autoInit()`, o `useJoinedCta(ref,options,deps)`. Se puede usar un enlace real en lugar del botón. API `expand()`, `collapse()`, `expanded`, `destroy()`.
12
+
13
+ Hover y foco equivalentes. En touch o pantallas <=420px se presenta una cápsula legible sin giro. Movimiento reducido conserva el cambio de estado instantáneo. Variables: `--mk-joined-duration`, `--mk-joined-bg`, `--mk-joined-color`. El contenido debe tener exactamente tres spans. No incluye navegación ni envío de correo.
@@ -0,0 +1,22 @@
1
+ import { createScope } from '../../core/scope';
2
+ import { inert, type Instance } from '../../core/types';
3
+
4
+ export interface JoinedCtaOptions { duration?: number; }
5
+ export interface JoinedCtaInstance extends Instance { expand(): void; collapse(): void; readonly expanded: boolean; }
6
+
7
+ /** Silvia's three contact capsules joining into one. Motion is entirely CSS. */
8
+ export function createJoinedCta(root: HTMLElement | null, options: JoinedCtaOptions = {}): JoinedCtaInstance {
9
+ if (!root) return inert({ expand() {}, collapse() {}, expanded: false });
10
+ const scope = createScope();
11
+ let expanded = false;
12
+ let pointer = false;
13
+ scope.attr(root, 'style', root.getAttribute('style'));
14
+ scope.attr(root, 'data-mk-ready', '');
15
+ if (Number.isFinite(options.duration)) root.style.setProperty('--mk-joined-duration', `${Math.max(0, options.duration!)}s`);
16
+ const set = (value: boolean) => { if (!scope.disposed) { expanded = value; scope.attr(root, 'data-mk-expanded', value ? '' : null); } };
17
+ scope.on(root, 'pointerenter', () => { pointer = true; set(true); });
18
+ scope.on(root, 'pointerleave', () => { pointer = false; if (!root.contains(document.activeElement)) set(false); });
19
+ scope.on(root, 'focusin', () => set(true));
20
+ scope.on(root, 'focusout', (event) => { if (!pointer && !root.contains((event as FocusEvent).relatedTarget as Node | null)) set(false); });
21
+ return { expand: () => set(true), collapse: () => set(false), get expanded() { return expanded; }, destroy: () => scope.dispose() };
22
+ }
@@ -0,0 +1,17 @@
1
+ .mk-joined-cta { --mk-joined-duration: .5s; --mk-joined-bg: var(--mk-text); --mk-joined-color: var(--mk-bg); position: relative; display: inline-flex; align-items: center; justify-content: center; width: 17.7rem; height: 5.9rem; max-width: 100%; border: 0; padding: 0; background: transparent; text-decoration: none; color: var(--mk-joined-color); cursor: pointer; font: inherit; isolation: isolate; border-radius: var(--mk-radius-full); }
2
+ .mk-joined-cta::before { content: ''; position: absolute; left: 0; width: 7.3rem; height: 5.9rem; border-radius: 100px; background: var(--mk-joined-bg); z-index: -1; transition: width var(--mk-joined-duration) ease-in-out; }
3
+ .mk-joined-cta > span { position: relative; display: grid; place-items: center; flex-shrink: 0; background: var(--mk-joined-bg); border-radius: 100px; height: 5.9rem; transition: left var(--mk-joined-duration) ease-in-out, height var(--mk-joined-duration) ease-in-out, transform var(--mk-joined-duration) ease-in-out, background var(--mk-joined-duration); }
4
+ .mk-joined-cta > span:nth-child(1) { width: 7.3rem; left: 0; }
5
+ .mk-joined-cta > span:nth-child(2) { width: 4.5rem; left: 0; z-index: 2; }
6
+ .mk-joined-cta > span:nth-child(3) { width: 5.9rem; height: 3.05rem; left: -1.45rem; transform: rotate(-90deg); }
7
+ .mk-joined-cta:is(:hover,:focus-visible,[data-mk-expanded])::before { width: 17.7rem; }
8
+ .mk-joined-cta:is(:hover,:focus-visible,[data-mk-expanded]) > span:nth-child(1) { left: 3.2rem; }
9
+ .mk-joined-cta:is(:hover,:focus-visible,[data-mk-expanded]) > span:nth-child(2) { left: -.75rem; background: transparent; }
10
+ .mk-joined-cta:is(:hover,:focus-visible,[data-mk-expanded]) > span:nth-child(3) { left: -3.65rem; height: 5.9rem; transform: rotate(0); }
11
+ .mk-joined-cta:focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 5px; }
12
+ @media (hover: none), (max-width: 420px) {
13
+ .mk-joined-cta { width: auto; height: auto; padding: 1.25rem 2rem; gap: .3em; background: var(--mk-joined-bg); }
14
+ .mk-joined-cta::before { display: none; }
15
+ .mk-joined-cta > span:nth-child(n) { width: auto; height: auto; left: 0; transform: none; background: transparent; }
16
+ }
17
+ @media (prefers-reduced-motion: reduce) { .mk-joined-cta::before, .mk-joined-cta > span { transition: none; } }
@@ -0,0 +1,31 @@
1
+ # local-clock
2
+
3
+ La hora de otra zona en vivo, como el «20:48 local time» del contacto de DOVV Studio. Se actualiza justo al cambiar el minuto (o el segundo, con `seconds`). En un `<time>` también mantiene el atributo `datetime`.
4
+
5
+ ## Vanilla / Astro
6
+
7
+ ```html
8
+ <p><span class="mk-status__dot"></span> <time data-mk="local-clock" data-mk-time-zone="America/Mexico_City">--:--</time> hora local</p>
9
+ ```
10
+
11
+ Con `autoInit()` las opciones van como `data-mk-<opción>`. Sin `autoInit`: `createLocalClock(el, opciones)`, que devuelve `text` y `destroy()`. El contenido inicial (por ejemplo `--:--`) se ve sin JavaScript y vuelve con `destroy()`.
12
+
13
+ ## React
14
+
15
+ ```tsx
16
+ const ref = useRef<HTMLTimeElement>(null);
17
+ useLocalClock(ref, { timeZone: 'America/Argentina/Buenos_Aires' });
18
+ return <time ref={ref}>--:--</time>;
19
+ ```
20
+
21
+ ## Opciones
22
+
23
+ | Opción | Por defecto | Qué hace |
24
+ |---|---|---|
25
+ | `timeZone` | la del visitante | Zona IANA |
26
+ | `locale` | `'es'` | Idioma del formato |
27
+ | `hour12` | `false` | 12 h con a. m./p. m. |
28
+ | `seconds` | `false` | Mostrar y actualizar segundos |
29
+ | `format` | — | `Intl.DateTimeFormatOptions` completo |
30
+
31
+ Una zona o un idioma desconocidos usan los del visitante en lugar de fallar. No anuncia cada cambio a lectores de pantalla.
@@ -0,0 +1,61 @@
1
+ import { createScope } from '../../core/scope';
2
+ import { inert, type Instance } from '../../core/types';
3
+
4
+ /*
5
+ * Local clock (DOVV Studio contact: "20:48 local time"): the time in a given
6
+ * zone, refreshed on the minute (or the second). A <time> element also gets
7
+ * its datetime attribute. The text node is replaced; destroy() puts it back.
8
+ */
9
+
10
+ export interface LocalClockOptions {
11
+ /** IANA zone, e.g. "America/Mexico_City". The visitor's zone by default. */
12
+ timeZone?: string;
13
+ locale?: string;
14
+ hour12?: boolean;
15
+ seconds?: boolean;
16
+ /** Full control over the format (overrides hour12/seconds). */
17
+ format?: Intl.DateTimeFormatOptions;
18
+ }
19
+
20
+ export interface LocalClockInstance extends Instance {
21
+ /** Text currently shown. */
22
+ readonly text: string;
23
+ }
24
+
25
+ export function createLocalClock(root: HTMLElement | null, options: LocalClockOptions = {}): LocalClockInstance {
26
+ if (!root) return inert({ text: '' });
27
+ const scope = createScope();
28
+ const original = [...root.childNodes];
29
+ scope.add(() => root.replaceChildren(...original));
30
+ scope.attr(root, 'data-mk-ready', '');
31
+ let formatter: Intl.DateTimeFormat;
32
+ try {
33
+ formatter = new Intl.DateTimeFormat(options.locale ?? 'es', options.format ?? {
34
+ hour: '2-digit',
35
+ minute: '2-digit',
36
+ ...(options.seconds ? { second: '2-digit' } : {}),
37
+ hour12: options.hour12 ?? false,
38
+ timeZone: options.timeZone,
39
+ });
40
+ } catch {
41
+ // Unknown zone or locale: the visitor's own.
42
+ formatter = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hour12: options.hour12 ?? false });
43
+ }
44
+ let text = '';
45
+ const isTime = root instanceof HTMLTimeElement;
46
+ const render = () => {
47
+ const now = new Date();
48
+ text = formatter.format(now);
49
+ if (root.textContent !== text) root.textContent = text;
50
+ if (isTime) scope.attr(root, 'datetime', now.toISOString());
51
+ };
52
+ const step = options.seconds || options.format?.second ? 1000 : 60000;
53
+ const schedule = () => {
54
+ // Land just after the next boundary so the minute flips with the system clock.
55
+ const wait = step - (Date.now() % step) + 20;
56
+ scope.timeout(() => { if (!scope.disposed) { render(); schedule(); } }, wait);
57
+ };
58
+ render();
59
+ schedule();
60
+ return { get text() { return text; }, destroy: () => scope.dispose() };
61
+ }
@@ -0,0 +1,46 @@
1
+ # scroll-dock
2
+
3
+ La navegación rápida de DOVV Studio: una cápsula invertida fija abajo y al centro. Aparece cuando el scroll pasa de 0.6 alturas de ventana y se esconde cuando el footer llega a 40 px del borde inferior. Sube desde `translateY(140%)` en 0.6 s `cubic-bezier(.32,1.25,.5,1)`, un resorte con un poco de rebote, y su opacidad cambia en 0.3 s.
4
+
5
+ ## Vanilla / Astro
6
+
7
+ ```html
8
+ <nav class="mk-scroll-dock" data-mk="scroll-dock" data-mk-hide-when="footer" aria-label="Navegación rápida">
9
+ <a href="#proyectos">Proyectos</a>
10
+ <a href="#proceso">Cómo trabajamos</a>
11
+ <a href="#precios">Precios</a>
12
+ <a href="#contacto">Contacto</a>
13
+ </nav>
14
+ ```
15
+
16
+ Con `autoInit()` las opciones van como `data-mk-<opción>` (`data-mk-threshold`, `data-mk-hide-when`, `data-mk-hide-offset`). Sin `autoInit`: `createScrollDock(el, opciones)`, que devuelve `visible`, `update()` y `destroy()`. Cada cambio emite `mk:change` con `{ visible }`.
17
+
18
+ ## React
19
+
20
+ ```tsx
21
+ const ref = useRef<HTMLElement>(null);
22
+ useScrollDock(ref, { hideWhen: 'footer' });
23
+ return <nav ref={ref} className="mk-scroll-dock" aria-label="Navegación rápida">…</nav>;
24
+ ```
25
+
26
+ ## Opciones
27
+
28
+ | Opción | Por defecto | Qué hace |
29
+ |---|---|---|
30
+ | `threshold` | `0.6` | Scroll mínimo, en alturas de ventana |
31
+ | `hideWhen` | — | Elemento o selector que la esconde al llegar abajo |
32
+ | `hideOffset` | `40` | px antes del borde inferior |
33
+ | `scroller` | `window` | Contenedor con scroll propio (elemento o selector, buscado desde la cápsula hacia arriba) |
34
+ | `onChange(visible)` | — | Al aparecer o esconderse |
35
+
36
+ Dentro de un contenedor con scroll propio, añade `.mk-scroll-dock--sticky` y pasa `scroller`. Así queda pegada al borde inferior del contenedor en vez de al de la ventana. Ponla como último hijo del contenido. No ocupa sitio: se superpone a la última sección, como la versión fija, así que no deja una franja vacía al final.
37
+
38
+ ## Variables CSS
39
+
40
+ `--mk-scroll-dock-bottom` (24 px) y `--mk-scroll-dock-height` (58 px, 64 px en móvil; sólo la usa `--sticky`; cámbiala si cambias el alto de los botones).
41
+
42
+ En pantallas de 680 px o menos los botones pasan a pestañas (icono sobre etiqueta, 11 px), así caben cuatro o cinco en un teléfono. Si aun así no caben, la cápsula se desplaza por dentro y nunca desborda la página.
43
+
44
+ ## Accesibilidad
45
+
46
+ Los enlaces siguen en el orden de tabulación. Si reciben foco con la cápsula escondida, `:focus-within` la muestra. Con movimiento reducido sólo cambia la opacidad.
@@ -0,0 +1,72 @@
1
+ import { createScope } from '../../core/scope';
2
+ import { inert, type Instance } from '../../core/types';
3
+
4
+ /*
5
+ * Scroll dock (DOVV Studio quick navigation): a floating capsule that rises
6
+ * once the page is scrolled past `threshold` viewports and sinks again when
7
+ * `hideWhen` (the footer) comes within `hideOffset` px of the bottom edge.
8
+ * The rise is CSS: translateY 140 % → 0 in .6 s cubic-bezier(.32,1.25,.5,1),
9
+ * opacity .3 s. Keyboard focus inside shows it whatever the scroll.
10
+ */
11
+
12
+ export interface ScrollDockOptions {
13
+ /** Scroll distance before it shows, in viewport heights. */
14
+ threshold?: number;
15
+ /** Element (or selector) that sends it away when it reaches the viewport bottom. */
16
+ hideWhen?: Element | string | null;
17
+ /** px above the bottom edge at which `hideWhen` counts as reached. */
18
+ hideOffset?: number;
19
+ /** Scrolling element (or selector, looked up from the dock outwards); the window by default. */
20
+ scroller?: HTMLElement | string | null;
21
+ onChange?: (visible: boolean) => void;
22
+ }
23
+
24
+ export interface ScrollDockInstance extends Instance {
25
+ readonly visible: boolean;
26
+ /** Re-read the scroll position (after layout changes). */
27
+ update(): void;
28
+ }
29
+
30
+ export function createScrollDock(root: HTMLElement | null, options: ScrollDockOptions = {}): ScrollDockInstance {
31
+ if (!root) return inert({ visible: false, update() {} });
32
+ const scope = createScope();
33
+ const threshold = Number.isFinite(options.threshold) ? Math.max(0, options.threshold!) : 0.6;
34
+ const hideOffset = Number.isFinite(options.hideOffset) ? options.hideOffset! : 40;
35
+ const scroller = typeof options.scroller === 'string'
36
+ ? root.closest<HTMLElement>(options.scroller) ?? document.querySelector<HTMLElement>(options.scroller)
37
+ : options.scroller ?? null;
38
+ const hideTarget = () => (typeof options.hideWhen === 'string' ? document.querySelector(options.hideWhen) : options.hideWhen ?? null);
39
+ let visible = false;
40
+ let queued = false;
41
+ scope.attr(root, 'data-mk-ready', '');
42
+
43
+ const update = () => {
44
+ queued = false;
45
+ if (scope.disposed) return;
46
+ const offset = scroller ? scroller.scrollTop : window.scrollY;
47
+ const viewport = scroller ? scroller.clientHeight : window.innerHeight;
48
+ const bottom = scroller ? scroller.getBoundingClientRect().bottom : window.innerHeight;
49
+ const end = hideTarget();
50
+ const reachedEnd = end ? end.getBoundingClientRect().top < bottom - hideOffset : false;
51
+ const next = offset > viewport * threshold && !reachedEnd;
52
+ if (next === visible) return;
53
+ visible = next;
54
+ scope.attr(root, 'data-mk-visible', next ? '' : null);
55
+ options.onChange?.(next);
56
+ root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { visible: next } }));
57
+ };
58
+ const queue = () => {
59
+ if (queued) return;
60
+ queued = true;
61
+ requestAnimationFrame(update);
62
+ };
63
+ scope.on(scroller ?? window, 'scroll', queue, { passive: true });
64
+ scope.on(window, 'resize', queue, { passive: true });
65
+ update();
66
+
67
+ return {
68
+ get visible() { return visible; },
69
+ update,
70
+ destroy: () => scope.dispose(),
71
+ };
72
+ }
@@ -0,0 +1,70 @@
1
+ /* scroll-dock — inverted capsule at the bottom centre; rises with a soft overshoot. */
2
+ .mk-scroll-dock {
3
+ position: fixed;
4
+ left: 50%;
5
+ bottom: var(--mk-scroll-dock-bottom, 24px);
6
+ z-index: 20;
7
+ display: flex;
8
+ gap: 4px;
9
+ max-width: calc(100vw - 32px);
10
+ padding: 6px;
11
+ /* Too many items for the width: scroll inside the capsule, never overflow the page. */
12
+ overflow-x: auto;
13
+ scrollbar-width: none;
14
+ border-radius: 100px;
15
+ background: var(--mk-text);
16
+ color: var(--mk-bg);
17
+ box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--mk-bg) 20%, transparent), 0 12px 30px -10px rgb(0 0 0 / 0.5);
18
+ opacity: 0;
19
+ pointer-events: none;
20
+ transform: translate(-50%, 140%);
21
+ transition: transform 0.6s cubic-bezier(0.32, 1.25, 0.5, 1), opacity 0.3s;
22
+ }
23
+ .mk-scroll-dock[data-mk-visible],
24
+ .mk-scroll-dock:focus-within {
25
+ opacity: 1;
26
+ pointer-events: auto;
27
+ transform: translate(-50%, 0);
28
+ }
29
+ /* Inside a scrolling container: sticks to its bottom edge instead of the viewport's.
30
+ Scrollable overflow counts the un-stuck box plus its transform, so the negative
31
+ margin lifts that box by its height + bottom gap and the hide offset equals the
32
+ gap: nothing reaches past the last section, no empty band at the end. */
33
+ .mk-scroll-dock--sticky {
34
+ position: sticky;
35
+ left: auto;
36
+ width: max-content;
37
+ max-width: calc(100% - 16px);
38
+ margin: calc(-1 * (var(--mk-scroll-dock-height, 58px) + var(--mk-scroll-dock-bottom, 24px))) auto 0;
39
+ transform: translateY(var(--mk-scroll-dock-bottom, 24px)) scale(0.96);
40
+ }
41
+ .mk-scroll-dock--sticky:is([data-mk-visible], :focus-within) { transform: none; }
42
+ .mk-scroll-dock > :is(a, button) {
43
+ display: flex;
44
+ align-items: center;
45
+ gap: 8px;
46
+ height: 46px;
47
+ padding: 0 18px;
48
+ border: 0;
49
+ border-radius: 100px;
50
+ background: transparent;
51
+ color: inherit;
52
+ font: inherit;
53
+ font-size: 15px;
54
+ font-weight: 500;
55
+ white-space: nowrap;
56
+ text-decoration: none;
57
+ cursor: pointer;
58
+ transition: background 0.25s;
59
+ }
60
+ .mk-scroll-dock > :is(a, button):hover { background: color-mix(in srgb, var(--mk-bg) 15%, transparent); }
61
+ .mk-scroll-dock > :is(a, button):focus-visible { outline: 2px solid var(--mk-bg); outline-offset: -2px; }
62
+ .mk-scroll-dock::-webkit-scrollbar { display: none; }
63
+ /* Phones: a tab bar, icon over label, so four or five items fit in 360 px. */
64
+ @media (max-width: 680px) {
65
+ .mk-scroll-dock > :is(a, button) { flex-direction: column; justify-content: center; gap: 2px; height: 52px; padding: 0 12px; border-radius: 26px; font-size: 11px; }
66
+ .mk-scroll-dock--sticky { margin-top: calc(-1 * (var(--mk-scroll-dock-height, 64px) + var(--mk-scroll-dock-bottom, 24px))); }
67
+ }
68
+ @media (prefers-reduced-motion: reduce) {
69
+ .mk-scroll-dock { transition: opacity 0.2s; }
70
+ }
@@ -0,0 +1,59 @@
1
+ # sticker
2
+
3
+ Un sticker troquelado y holográfico, como los del hero y el contacto de DOVV Studio:
4
+
5
+ - **Entrada:** pasa de escala .4 y opacidad 0 a su tamaño en 0.7 s con la curva `slot` (`.34,1.56,.64,1`). Para escalonarlos, usa `--mk-sticker-delay` (0.15 s entre uno y otro en la referencia).
6
+ - **Hover:** la rotación pasa a `--mk-sticker-rot × −0.6` y la escala a 1.08, en 0.4 s con la misma curva.
7
+ - **Holograma:** el foil y el brillo siguen al ratón sobre el sticker (lerp .08). Sin ratón, o en táctil, derivan solos. Sólo se anima mientras el sticker es visible.
8
+ - **Despegar:** al presionar, el borde más cercano se levanta un poco y el sticker conserva la pose que tenía, sin saltos aunque estuviera en hover. Al arrastrar en cualquier dirección (también hacia fuera, como al jalarlo), el pliegue avanza con la distancia y deja ver un dorso de papel con sombra. Si lo sueltas pronto, se vuelve a pegar en 380 ms. Pasado el 30 % del área, o tras arrastrar la mitad de su lado largo (80 px como mínimo), se suelta: sigue al puntero (lerp .22), crece un 8 % y se inclina con la velocidad (±16°). Al soltarlo cae con gravedad (1600 px/s²) y giro, y a los 2.5 s vuelve a su sitio con la entrada.
9
+
10
+ El pliegue es propio: el frente se recorta con `clip-path` a un lado de la línea de pliegue y el dorso es una copia reflejada sobre ella. No usa Peel.js ni dependencias. Respecto a la referencia, el kit añade cuatro cosas: congela la pose al presionar, despega también al jalar hacia fuera, limita el estiramiento (la esquina no se aleja de la arista opuesta más que su distancia original) y sombrea el dorso. Así no levanta de golpe toda una píldora larga.
11
+
12
+ ## Vanilla / Astro
13
+
14
+ ```html
15
+ <div class="hero" style="position: relative">
16
+ <div class="mk-sticker" data-mk="sticker" aria-hidden="true"
17
+ style="position: absolute; left: -4%; top: -12%; --mk-sticker-rot: -9deg; --mk-sticker-delay: .9s">
18
+ <span class="mk-sticker__pill">Framer expert</span>
19
+ </div>
20
+ <div class="mk-sticker mk-sticker--lime" data-mk="sticker" aria-hidden="true" style="position: absolute; right: 0; bottom: 0; --mk-sticker-rot: 7deg">
21
+ <span class="mk-sticker__badge">4.9 ★</span>
22
+ </div>
23
+ </div>
24
+ ```
25
+
26
+ El arte puede ser `.mk-sticker__pill` (cápsula), `.mk-sticker__badge` (círculo, `--mk-sticker-size`) o cualquier elemento con `.mk-sticker__art` (`--mk-sticker-radius`). Paletas: por defecto naranja, `--pink`, `--blue`, `--lime` y `--gold`. Para una propia, define `--mk-sticker-foil` (un gradiente) y `--mk-sticker-ink`.
27
+
28
+ Sin `autoInit`: `createSticker(el, opciones)`. Devuelve `state` (`idle | peeling | carried | falling | away`) y `destroy()`.
29
+
30
+ ## React
31
+
32
+ ```tsx
33
+ const ref = useRef<HTMLDivElement>(null);
34
+ useSticker(ref, { returnDelay: 2500 });
35
+ return <div ref={ref} className="mk-sticker mk-sticker--blue" aria-hidden="true"><span className="mk-sticker__pill">hey!</span></div>;
36
+ ```
37
+
38
+ Mientras está despegado, el elemento se mueve a `document.body` con `position: fixed` (así no lo recortan los contenedores) y después vuelve a su padre. No desmontes el sticker en ese intervalo; si lo haces, `destroy()` lo devuelve a su sitio.
39
+
40
+ ## Opciones
41
+
42
+ | Opción | Por defecto | Qué hace |
43
+ |---|---|---|
44
+ | `peel` | `true` | Permite despegarlo arrastrando |
45
+ | `holo` | `true` | Foil y brillo que siguen al puntero |
46
+ | `detachAt` | `0.3` | Fracción del área levantada a partir de la cual se suelta |
47
+ | `detachDistance` | mitad del lado largo, mín. 80 | px de arrastre (sin escala) a partir de los cuales se suelta |
48
+ | `returnDelay` | `2500` | ms oculto después de caer |
49
+ | `onPeel`, `onReturn` | — | Al soltarse y al volver |
50
+
51
+ ## Variables CSS
52
+
53
+ `--mk-sticker-rot`, `--mk-sticker-delay`, `--mk-sticker-scale` (la referencia usa .8 bajo 1200 px y .6 bajo 810 px), `--mk-sticker-foil`, `--mk-sticker-ink`, `--mk-sticker-size` y `--mk-sticker-radius`.
54
+
55
+ El dorso de papel cubre `.mk-sticker__pill`, `__badge` y `__art`. Si el arte es una forma libre (un `<img>` o `<svg>` con transparencia), añade `data-mk-sticker-back="silhouette"` al sticker y el dorso será su silueta blanca.
56
+
57
+ ## Accesibilidad
58
+
59
+ Es decoración: márcalo con `aria-hidden="true"` y deja el texto importante en el contenido. Si el sticker transmite información, usa `peel: false`. Con movimiento reducido no hay entrada, hover ni holograma animado; despegar sigue funcionando porque lo hace el usuario, pero sin caída: se oculta y vuelve.