not-a-spinner 0.1.0 → 0.2.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/dist/index.cjs CHANGED
@@ -301,9 +301,11 @@ function getPhrasesForConfig(messages, locale) {
301
301
  function useTextRotation({
302
302
  phrases: inputPhrases,
303
303
  interval = 3e3,
304
- animationDuration = 400
304
+ animationDuration = 400,
305
+ manual = false
305
306
  }) {
306
307
  const [currentIndex, setCurrentIndex] = (0, import_react.useState)(0);
308
+ const [cycleId, setCycleId] = (0, import_react.useState)(0);
307
309
  const [isExiting, setIsExiting] = (0, import_react.useState)(false);
308
310
  const [isEntering, setIsEntering] = (0, import_react.useState)(false);
309
311
  const poolRef = (0, import_react.useRef)(inputPhrases);
@@ -314,10 +316,15 @@ function useTextRotation({
314
316
  if (!initializedRef.current) {
315
317
  poolRef.current = shufflePhrases(inputPhrases);
316
318
  initializedRef.current = true;
317
- setCurrentIndex(0);
319
+ setCycleId((c) => c + 1);
318
320
  }
319
321
  }, []);
322
+ const inputChangedRef = (0, import_react.useRef)(false);
320
323
  (0, import_react.useEffect)(() => {
324
+ if (!inputChangedRef.current) {
325
+ inputChangedRef.current = true;
326
+ return;
327
+ }
321
328
  poolRef.current = shufflePhrases(inputPhrases);
322
329
  setCurrentIndex(0);
323
330
  }, [inputPhrases]);
@@ -330,8 +337,20 @@ function useTextRotation({
330
337
  poolRef.current = combined;
331
338
  }
332
339
  }, []);
340
+ const advancePhrase = (0, import_react.useCallback)(() => {
341
+ setCurrentIndex((prev) => {
342
+ const next = prev + 1;
343
+ if (next >= poolRef.current.length) {
344
+ poolRef.current = shufflePhrases(poolRef.current);
345
+ return 0;
346
+ }
347
+ return next;
348
+ });
349
+ setCycleId((c) => c + 1);
350
+ }, []);
333
351
  (0, import_react.useEffect)(() => {
334
352
  mountedRef.current = true;
353
+ if (manual) return;
335
354
  if (poolRef.current.length <= 1) return;
336
355
  timerRef.current = setInterval(() => {
337
356
  if (!mountedRef.current) return;
@@ -361,14 +380,16 @@ function useTextRotation({
361
380
  timerRef.current = null;
362
381
  }
363
382
  };
364
- }, [interval, animationDuration]);
383
+ }, [interval, animationDuration, manual]);
365
384
  const pool = poolRef.current;
366
385
  const phrase = pool.length > 0 ? pool[currentIndex % pool.length] : "";
367
386
  return {
368
387
  currentPhrase: phrase,
369
388
  isExiting,
370
389
  isEntering,
371
- appendPhrases
390
+ appendPhrases,
391
+ advancePhrase,
392
+ cycleId
372
393
  };
373
394
  }
374
395
 
@@ -409,9 +430,10 @@ var NotASpinner = React.forwardRef(
409
430
  () => getPhrasesForConfig(messages, locale),
410
431
  [messages, locale]
411
432
  );
412
- const { currentPhrase, isExiting, isEntering, appendPhrases } = useTextRotation({
433
+ const { currentPhrase, isExiting, isEntering, appendPhrases, advancePhrase, cycleId } = useTextRotation({
413
434
  phrases: resolvedPhrases,
414
- interval
435
+ interval,
436
+ manual: animation === "typewriter"
415
437
  });
416
438
  React.useEffect(() => {
417
439
  if (!fetchPhrase) return;
@@ -445,70 +467,91 @@ var NotASpinner = React.forwardRef(
445
467
  ref,
446
468
  className: cn(notASpinnerVariants({ size, animation }), className),
447
469
  currentPhrase,
470
+ cycleId,
448
471
  interval,
449
472
  dots,
473
+ onEraseComplete: advancePhrase,
450
474
  ...props
451
475
  }
452
476
  );
453
477
  }
454
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
478
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
455
479
  "div",
456
480
  {
457
481
  ref,
458
482
  className: cn(notASpinnerVariants({ size, animation }), className),
459
483
  ...props,
460
- children: [
461
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "nas-text-wrapper", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: getAnimationClass(), children: currentPhrase }) }),
484
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: getAnimationClass(), children: [
485
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: currentPhrase }),
462
486
  dots && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "nas-dots", children: "..." })
463
- ]
487
+ ] })
464
488
  }
465
489
  );
466
490
  }
467
491
  );
468
492
  NotASpinner.displayName = "NotASpinner";
469
- var TypewriterRenderer = React.forwardRef(({ className, currentPhrase, interval, dots, ...props }, ref) => {
493
+ var TypewriterRenderer = React.forwardRef(({ className, currentPhrase, cycleId, interval, dots, onEraseComplete, ...props }, ref) => {
470
494
  const [displayedChars, setDisplayedChars] = React.useState(0);
495
+ const [displayedDots, setDisplayedDots] = React.useState(0);
471
496
  const [phase, setPhase] = React.useState(
472
497
  "typing"
473
498
  );
474
499
  const phraseRef = React.useRef(currentPhrase);
500
+ const eraseCalledRef = React.useRef(false);
475
501
  React.useEffect(() => {
476
502
  phraseRef.current = currentPhrase;
477
503
  setDisplayedChars(0);
504
+ setDisplayedDots(0);
478
505
  setPhase("typing");
479
- }, [currentPhrase]);
506
+ eraseCalledRef.current = false;
507
+ }, [cycleId]);
480
508
  React.useEffect(() => {
481
509
  const totalChars = phraseRef.current.length;
482
510
  if (totalChars === 0) return;
483
511
  const typingSpeed = 50;
484
512
  const erasingSpeed = 30;
513
+ const dotsTypingDuration = 3 * typingSpeed;
514
+ const dotsDismissDuration = 3 * erasingSpeed;
485
515
  const typingDuration = totalChars * typingSpeed;
486
516
  const erasingDuration = totalChars * erasingSpeed;
487
- const holdDuration = Math.max(interval - typingDuration - erasingDuration, 500);
517
+ const holdDuration = Math.max(interval - typingDuration - dotsTypingDuration - dotsDismissDuration - erasingDuration, 500);
488
518
  let timer;
489
519
  if (phase === "typing") {
490
520
  if (displayedChars < totalChars) {
491
521
  timer = setTimeout(() => setDisplayedChars((c) => c + 1), typingSpeed);
522
+ } else {
523
+ timer = setTimeout(() => setPhase("typing-dots"), 0);
524
+ }
525
+ } else if (phase === "typing-dots") {
526
+ if (displayedDots < 3) {
527
+ timer = setTimeout(() => setDisplayedDots((d) => d + 1), typingSpeed);
492
528
  } else {
493
529
  timer = setTimeout(() => setPhase("holding"), 0);
494
530
  }
495
531
  } else if (phase === "holding") {
496
- timer = setTimeout(() => setPhase("erasing"), holdDuration);
532
+ timer = setTimeout(() => setPhase("erasing-dots"), holdDuration);
533
+ } else if (phase === "erasing-dots") {
534
+ if (displayedDots > 0) {
535
+ timer = setTimeout(() => setDisplayedDots((d) => d - 1), erasingSpeed);
536
+ } else {
537
+ timer = setTimeout(() => setPhase("erasing"), 0);
538
+ }
497
539
  } else if (phase === "erasing") {
498
540
  if (displayedChars > 0) {
499
541
  timer = setTimeout(() => setDisplayedChars((c) => c - 1), erasingSpeed);
542
+ } else if (!eraseCalledRef.current) {
543
+ eraseCalledRef.current = true;
544
+ onEraseComplete();
500
545
  }
501
546
  }
502
547
  return () => clearTimeout(timer);
503
- }, [phase, displayedChars, interval]);
548
+ }, [phase, displayedChars, displayedDots, interval, onEraseComplete]);
504
549
  const displayedText = phraseRef.current.slice(0, displayedChars);
505
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { ref, className, ...props, children: [
506
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "nas-text-wrapper", children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
507
- displayedText,
508
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "nas-cursor" })
509
- ] }) }),
510
- dots && displayedChars > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "nas-dots", children: "..." })
511
- ] });
550
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { ref, className, ...props, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "nas-text-wrapper", children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
551
+ displayedText,
552
+ dots && displayedChars > 0 && displayedDots > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "nas-dots", children: "...".slice(0, displayedDots) }),
553
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "nas-cursor" })
554
+ ] }) }) });
512
555
  });
513
556
  TypewriterRenderer.displayName = "TypewriterRenderer";
514
557
 
package/dist/index.d.cts CHANGED
@@ -24,14 +24,17 @@ interface UseTextRotationOptions {
24
24
  phrases: string[];
25
25
  interval?: number;
26
26
  animationDuration?: number;
27
+ manual?: boolean;
27
28
  }
28
29
  interface UseTextRotationReturn {
29
30
  currentPhrase: string;
30
31
  isExiting: boolean;
31
32
  isEntering: boolean;
32
33
  appendPhrases: (newPhrases: string[]) => void;
34
+ advancePhrase: () => void;
35
+ cycleId: number;
33
36
  }
34
- declare function useTextRotation({ phrases: inputPhrases, interval, animationDuration, }: UseTextRotationOptions): UseTextRotationReturn;
37
+ declare function useTextRotation({ phrases: inputPhrases, interval, animationDuration, manual, }: UseTextRotationOptions): UseTextRotationReturn;
35
38
 
36
39
  declare function createAnthropicFetcher(endpoint: string, locale?: string): () => Promise<string>;
37
40
  declare function createOpenAIFetcher(endpoint: string, locale?: string): () => Promise<string>;
package/dist/index.d.ts CHANGED
@@ -24,14 +24,17 @@ interface UseTextRotationOptions {
24
24
  phrases: string[];
25
25
  interval?: number;
26
26
  animationDuration?: number;
27
+ manual?: boolean;
27
28
  }
28
29
  interface UseTextRotationReturn {
29
30
  currentPhrase: string;
30
31
  isExiting: boolean;
31
32
  isEntering: boolean;
32
33
  appendPhrases: (newPhrases: string[]) => void;
34
+ advancePhrase: () => void;
35
+ cycleId: number;
33
36
  }
34
- declare function useTextRotation({ phrases: inputPhrases, interval, animationDuration, }: UseTextRotationOptions): UseTextRotationReturn;
37
+ declare function useTextRotation({ phrases: inputPhrases, interval, animationDuration, manual, }: UseTextRotationOptions): UseTextRotationReturn;
35
38
 
36
39
  declare function createAnthropicFetcher(endpoint: string, locale?: string): () => Promise<string>;
37
40
  declare function createOpenAIFetcher(endpoint: string, locale?: string): () => Promise<string>;
package/dist/index.mjs CHANGED
@@ -257,9 +257,11 @@ function getPhrasesForConfig(messages, locale) {
257
257
  function useTextRotation({
258
258
  phrases: inputPhrases,
259
259
  interval = 3e3,
260
- animationDuration = 400
260
+ animationDuration = 400,
261
+ manual = false
261
262
  }) {
262
263
  const [currentIndex, setCurrentIndex] = useState(0);
264
+ const [cycleId, setCycleId] = useState(0);
263
265
  const [isExiting, setIsExiting] = useState(false);
264
266
  const [isEntering, setIsEntering] = useState(false);
265
267
  const poolRef = useRef(inputPhrases);
@@ -270,10 +272,15 @@ function useTextRotation({
270
272
  if (!initializedRef.current) {
271
273
  poolRef.current = shufflePhrases(inputPhrases);
272
274
  initializedRef.current = true;
273
- setCurrentIndex(0);
275
+ setCycleId((c) => c + 1);
274
276
  }
275
277
  }, []);
278
+ const inputChangedRef = useRef(false);
276
279
  useEffect(() => {
280
+ if (!inputChangedRef.current) {
281
+ inputChangedRef.current = true;
282
+ return;
283
+ }
277
284
  poolRef.current = shufflePhrases(inputPhrases);
278
285
  setCurrentIndex(0);
279
286
  }, [inputPhrases]);
@@ -286,8 +293,20 @@ function useTextRotation({
286
293
  poolRef.current = combined;
287
294
  }
288
295
  }, []);
296
+ const advancePhrase = useCallback(() => {
297
+ setCurrentIndex((prev) => {
298
+ const next = prev + 1;
299
+ if (next >= poolRef.current.length) {
300
+ poolRef.current = shufflePhrases(poolRef.current);
301
+ return 0;
302
+ }
303
+ return next;
304
+ });
305
+ setCycleId((c) => c + 1);
306
+ }, []);
289
307
  useEffect(() => {
290
308
  mountedRef.current = true;
309
+ if (manual) return;
291
310
  if (poolRef.current.length <= 1) return;
292
311
  timerRef.current = setInterval(() => {
293
312
  if (!mountedRef.current) return;
@@ -317,14 +336,16 @@ function useTextRotation({
317
336
  timerRef.current = null;
318
337
  }
319
338
  };
320
- }, [interval, animationDuration]);
339
+ }, [interval, animationDuration, manual]);
321
340
  const pool = poolRef.current;
322
341
  const phrase = pool.length > 0 ? pool[currentIndex % pool.length] : "";
323
342
  return {
324
343
  currentPhrase: phrase,
325
344
  isExiting,
326
345
  isEntering,
327
- appendPhrases
346
+ appendPhrases,
347
+ advancePhrase,
348
+ cycleId
328
349
  };
329
350
  }
330
351
 
@@ -365,9 +386,10 @@ var NotASpinner = React.forwardRef(
365
386
  () => getPhrasesForConfig(messages, locale),
366
387
  [messages, locale]
367
388
  );
368
- const { currentPhrase, isExiting, isEntering, appendPhrases } = useTextRotation({
389
+ const { currentPhrase, isExiting, isEntering, appendPhrases, advancePhrase, cycleId } = useTextRotation({
369
390
  phrases: resolvedPhrases,
370
- interval
391
+ interval,
392
+ manual: animation === "typewriter"
371
393
  });
372
394
  React.useEffect(() => {
373
395
  if (!fetchPhrase) return;
@@ -401,70 +423,91 @@ var NotASpinner = React.forwardRef(
401
423
  ref,
402
424
  className: cn(notASpinnerVariants({ size, animation }), className),
403
425
  currentPhrase,
426
+ cycleId,
404
427
  interval,
405
428
  dots,
429
+ onEraseComplete: advancePhrase,
406
430
  ...props
407
431
  }
408
432
  );
409
433
  }
410
- return /* @__PURE__ */ jsxs(
434
+ return /* @__PURE__ */ jsx(
411
435
  "div",
412
436
  {
413
437
  ref,
414
438
  className: cn(notASpinnerVariants({ size, animation }), className),
415
439
  ...props,
416
- children: [
417
- /* @__PURE__ */ jsx("div", { className: "nas-text-wrapper", children: /* @__PURE__ */ jsx("span", { className: getAnimationClass(), children: currentPhrase }) }),
440
+ children: /* @__PURE__ */ jsxs("span", { className: getAnimationClass(), children: [
441
+ /* @__PURE__ */ jsx("span", { children: currentPhrase }),
418
442
  dots && /* @__PURE__ */ jsx("span", { className: "nas-dots", children: "..." })
419
- ]
443
+ ] })
420
444
  }
421
445
  );
422
446
  }
423
447
  );
424
448
  NotASpinner.displayName = "NotASpinner";
425
- var TypewriterRenderer = React.forwardRef(({ className, currentPhrase, interval, dots, ...props }, ref) => {
449
+ var TypewriterRenderer = React.forwardRef(({ className, currentPhrase, cycleId, interval, dots, onEraseComplete, ...props }, ref) => {
426
450
  const [displayedChars, setDisplayedChars] = React.useState(0);
451
+ const [displayedDots, setDisplayedDots] = React.useState(0);
427
452
  const [phase, setPhase] = React.useState(
428
453
  "typing"
429
454
  );
430
455
  const phraseRef = React.useRef(currentPhrase);
456
+ const eraseCalledRef = React.useRef(false);
431
457
  React.useEffect(() => {
432
458
  phraseRef.current = currentPhrase;
433
459
  setDisplayedChars(0);
460
+ setDisplayedDots(0);
434
461
  setPhase("typing");
435
- }, [currentPhrase]);
462
+ eraseCalledRef.current = false;
463
+ }, [cycleId]);
436
464
  React.useEffect(() => {
437
465
  const totalChars = phraseRef.current.length;
438
466
  if (totalChars === 0) return;
439
467
  const typingSpeed = 50;
440
468
  const erasingSpeed = 30;
469
+ const dotsTypingDuration = 3 * typingSpeed;
470
+ const dotsDismissDuration = 3 * erasingSpeed;
441
471
  const typingDuration = totalChars * typingSpeed;
442
472
  const erasingDuration = totalChars * erasingSpeed;
443
- const holdDuration = Math.max(interval - typingDuration - erasingDuration, 500);
473
+ const holdDuration = Math.max(interval - typingDuration - dotsTypingDuration - dotsDismissDuration - erasingDuration, 500);
444
474
  let timer;
445
475
  if (phase === "typing") {
446
476
  if (displayedChars < totalChars) {
447
477
  timer = setTimeout(() => setDisplayedChars((c) => c + 1), typingSpeed);
478
+ } else {
479
+ timer = setTimeout(() => setPhase("typing-dots"), 0);
480
+ }
481
+ } else if (phase === "typing-dots") {
482
+ if (displayedDots < 3) {
483
+ timer = setTimeout(() => setDisplayedDots((d) => d + 1), typingSpeed);
448
484
  } else {
449
485
  timer = setTimeout(() => setPhase("holding"), 0);
450
486
  }
451
487
  } else if (phase === "holding") {
452
- timer = setTimeout(() => setPhase("erasing"), holdDuration);
488
+ timer = setTimeout(() => setPhase("erasing-dots"), holdDuration);
489
+ } else if (phase === "erasing-dots") {
490
+ if (displayedDots > 0) {
491
+ timer = setTimeout(() => setDisplayedDots((d) => d - 1), erasingSpeed);
492
+ } else {
493
+ timer = setTimeout(() => setPhase("erasing"), 0);
494
+ }
453
495
  } else if (phase === "erasing") {
454
496
  if (displayedChars > 0) {
455
497
  timer = setTimeout(() => setDisplayedChars((c) => c - 1), erasingSpeed);
498
+ } else if (!eraseCalledRef.current) {
499
+ eraseCalledRef.current = true;
500
+ onEraseComplete();
456
501
  }
457
502
  }
458
503
  return () => clearTimeout(timer);
459
- }, [phase, displayedChars, interval]);
504
+ }, [phase, displayedChars, displayedDots, interval, onEraseComplete]);
460
505
  const displayedText = phraseRef.current.slice(0, displayedChars);
461
- return /* @__PURE__ */ jsxs("div", { ref, className, ...props, children: [
462
- /* @__PURE__ */ jsx("div", { className: "nas-text-wrapper", children: /* @__PURE__ */ jsxs("span", { children: [
463
- displayedText,
464
- /* @__PURE__ */ jsx("span", { className: "nas-cursor" })
465
- ] }) }),
466
- dots && displayedChars > 0 && /* @__PURE__ */ jsx("span", { className: "nas-dots", children: "..." })
467
- ] });
506
+ return /* @__PURE__ */ jsx("div", { ref, className, ...props, children: /* @__PURE__ */ jsx("div", { className: "nas-text-wrapper", children: /* @__PURE__ */ jsxs("span", { children: [
507
+ displayedText,
508
+ dots && displayedChars > 0 && displayedDots > 0 && /* @__PURE__ */ jsx("span", { className: "nas-dots", children: "...".slice(0, displayedDots) }),
509
+ /* @__PURE__ */ jsx("span", { className: "nas-cursor" })
510
+ ] }) }) });
468
511
  });
469
512
  TypewriterRenderer.displayName = "TypewriterRenderer";
470
513
 
package/dist/styles.css CHANGED
@@ -79,12 +79,6 @@
79
79
  /* Dots */
80
80
  .nas-dots {
81
81
  margin-left: 2px;
82
- animation: nas-pulse 1.5s ease-in-out infinite;
83
- }
84
-
85
- @keyframes nas-pulse {
86
- 0%, 100% { opacity: 0.4; }
87
- 50% { opacity: 1; }
88
82
  }
89
83
 
90
84
  /* Reduced motion */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "not-a-spinner",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Because modern AI doesn't spin, it thinks. Replace loading spinners with AI-style rotating thinking phrases.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",