@passui/react 0.1.26 → 0.1.28

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.
@@ -10,6 +10,8 @@ export interface SegmentedControlProps<T extends string> {
10
10
  onChange: (value: NoInfer<T>) => void;
11
11
  /** Accessible name for the group and its compact picker. */
12
12
  label: string;
13
+ /** Custom classes for the control container. */
14
+ className?: string;
13
15
  }
14
16
  /**
15
17
  * Mutually exclusive choices with a sliding selection, as on Apple
@@ -17,4 +19,4 @@ export interface SegmentedControlProps<T extends string> {
17
19
  * translation, a narrow pane) it becomes a pop-up button instead of
18
20
  * squeezing or spilling its text.
19
21
  */
20
- export declare function SegmentedControl<T extends string>({ value, options, onChange, label, }: SegmentedControlProps<T>): import("react").JSX.Element;
22
+ export declare function SegmentedControl<T extends string>({ value, options, onChange, label, className, }: SegmentedControlProps<T>): import("react").JSX.Element;
@@ -10,6 +10,8 @@ export interface StatTileProps {
10
10
  /** Makes the tile a toggle, e.g. a filter. */
11
11
  onClick?: () => void;
12
12
  active?: boolean;
13
+ /** Custom classes for the tile container. */
14
+ className?: string;
13
15
  }
14
16
  /** One number with its label, optionally a toggle. */
15
- export declare function StatTile({ label, value, caption, tone, onClick, active }: StatTileProps): import("react").JSX.Element;
17
+ export declare function StatTile({ label, value, caption, tone, onClick, active, className, }: StatTileProps): import("react").JSX.Element;
@@ -12,6 +12,8 @@ export interface SwatchPickerProps<T extends string> {
12
12
  onChange: (value: T) => void;
13
13
  /** Accessible name for the group. */
14
14
  label: string;
15
+ /** Custom classes for the swatch container. */
16
+ className?: string;
15
17
  }
16
18
  /** Colour swatches with a check on the chosen one. */
17
- export declare function SwatchPicker<T extends string>({ value, options, onChange, label, }: SwatchPickerProps<T>): import("react").JSX.Element;
19
+ export declare function SwatchPicker<T extends string>({ value, options, onChange, label, className, }: SwatchPickerProps<T>): import("react").JSX.Element;
@@ -1,6 +1,9 @@
1
1
  import { type ComponentProps, type ReactNode } from "react";
2
+ /** Shared text control classes for components that reuse TextField's surface. */
2
3
  export declare const textControlClasses: string;
3
- export type TextFieldProps = ComponentProps<"input"> & {
4
+ /** Size scale shared across TextField, SecureField, and form fields. */
5
+ export type TextFieldSize = "xs" | "sm" | "md" | "lg";
6
+ export type TextFieldProps = Omit<ComponentProps<"input">, "size"> & {
4
7
  label: string;
5
8
  description?: ReactNode;
6
9
  errors?: string[];
@@ -16,5 +19,7 @@ export type TextFieldProps = ComponentProps<"input"> & {
16
19
  };
17
20
  /** Float the label into the outline on focus or when filled. @default "above" */
18
21
  labelPlacement?: "above" | "floating";
22
+ /** Field height and text scale. @default "md" */
23
+ size?: TextFieldSize;
19
24
  };
20
- export declare function TextField({ label, description, errors, icon, trailingIcon, action, revealLabels, labelPlacement, placeholder, type, id: suppliedId, className, ...props }: TextFieldProps): import("react").JSX.Element;
25
+ export declare function TextField({ label, description, errors, icon, trailingIcon, action, revealLabels, labelPlacement, size, placeholder, type, id: suppliedId, className, ...props }: TextFieldProps): import("react").JSX.Element;
@@ -13,5 +13,7 @@ export interface ToggleProps {
13
13
  mixed?: boolean;
14
14
  /** Keeps the accessible name while hiding the visible label. @default false */
15
15
  labelHidden?: boolean;
16
+ /** Custom classes for the toggle container. */
17
+ className?: string;
16
18
  }
17
- export declare function Toggle({ checked, onChange, label, disabled, style, mixed, labelHidden, }: ToggleProps): import("react").JSX.Element;
19
+ export declare function Toggle({ checked, onChange, label, disabled, style, mixed, labelHidden, className, }: ToggleProps): import("react").JSX.Element;
@@ -19,12 +19,37 @@ export declare const spring: {
19
19
  readonly type: "spring";
20
20
  };
21
21
  };
22
+ /**
23
+ * Apple's fluid overshoot: cubic-bezier(0.34, 1.56, 0.1, 1).
24
+ * Replaces traditional ease profiles with an authentic fluid bounce
25
+ * that overshoots and settles, matching Apple's custom interface physics.
26
+ * Used for zoom/matched-geometry transitions where the surface emerges
27
+ * from the trigger and snaps into place with a physical bounce.
28
+ */
29
+ export declare const fluidOvershoot: {
30
+ readonly duration: 0.4;
31
+ readonly ease: [number, number, number, number];
32
+ };
33
+ /**
34
+ * Exit motion: content collapses back toward its source.
35
+ * The surface shrinks toward the trigger's position while fading,
36
+ * like a water drop absorbing back into its origin.
37
+ */
38
+ export declare const fluidCollapse: {
39
+ readonly duration: 0.28;
40
+ readonly ease: [number, number, number, number];
41
+ };
22
42
  export type FloatingPanelSide = "above" | "below";
23
- /** Anchored panels settle toward their source without shrinking their text. */
43
+ /**
44
+ * Anchored panels settle toward their source without shrinking their text.
45
+ * Now uses origin-based scaling: the panel grows from the trigger's
46
+ * position (transform-origin set by the caller) with a scale + opacity
47
+ * crossfade, rather than a plain slide.
48
+ */
24
49
  export declare function floatingPanelMotion(side?: FloatingPanelSide): {
25
50
  readonly initial: {
26
51
  readonly opacity: 0;
27
- readonly scale: 1;
52
+ readonly scale: 0.92;
28
53
  readonly y: number;
29
54
  };
30
55
  readonly animate: {
@@ -34,16 +59,15 @@ export declare function floatingPanelMotion(side?: FloatingPanelSide): {
34
59
  };
35
60
  readonly exit: {
36
61
  readonly opacity: 0;
37
- readonly scale: 1;
62
+ readonly scale: 0.95;
38
63
  readonly y: number;
39
64
  };
40
65
  readonly transition: {
41
- readonly damping: 26;
42
- readonly stiffness: 240;
43
- readonly mass: 1;
44
- readonly type: "spring";
66
+ readonly duration: 0.4;
67
+ readonly ease: [number, number, number, number];
45
68
  readonly opacity: {
46
69
  readonly duration: 0.16;
70
+ readonly ease: [number, number, number, number];
47
71
  };
48
72
  };
49
73
  };
@@ -51,7 +75,7 @@ export declare function floatingPanelMotion(side?: FloatingPanelSide): {
51
75
  export declare const popoverMotion: {
52
76
  readonly initial: {
53
77
  readonly opacity: 0;
54
- readonly scale: 1;
78
+ readonly scale: 0.92;
55
79
  readonly y: number;
56
80
  };
57
81
  readonly animate: {
@@ -61,19 +85,68 @@ export declare const popoverMotion: {
61
85
  };
62
86
  readonly exit: {
63
87
  readonly opacity: 0;
64
- readonly scale: 1;
88
+ readonly scale: 0.95;
65
89
  readonly y: number;
66
90
  };
67
91
  readonly transition: {
68
- readonly damping: 26;
69
- readonly stiffness: 240;
70
- readonly mass: 1;
71
- readonly type: "spring";
92
+ readonly duration: 0.4;
93
+ readonly ease: [number, number, number, number];
72
94
  readonly opacity: {
73
95
  readonly duration: 0.16;
96
+ readonly ease: [number, number, number, number];
74
97
  };
75
98
  };
76
99
  };
100
+ /**
101
+ * Zoom/matched-geometry transition for dialogs and center-placed sheets.
102
+ * The surface starts small (scale 0.88) and invisible, then grows to
103
+ * full size with the fluid overshoot bounce. Content elements inside
104
+ * crossfade in slightly delayed so they phase into visibility
105
+ * asynchronously — not a harsh switch.
106
+ */
107
+ export declare const zoomReveal: {
108
+ readonly initial: {
109
+ readonly opacity: 0;
110
+ readonly scale: 0.88;
111
+ };
112
+ readonly animate: {
113
+ readonly opacity: 1;
114
+ readonly scale: 1;
115
+ };
116
+ readonly exit: {
117
+ readonly opacity: 0;
118
+ readonly scale: 0.92;
119
+ };
120
+ readonly transition: {
121
+ readonly duration: 0.4;
122
+ readonly ease: [number, number, number, number];
123
+ };
124
+ };
125
+ /**
126
+ * Content crossfade for elements inside an overlay.
127
+ * Rather than a harsh switch, sub-elements scale and phase into
128
+ * visibility asynchronously. Use for children of dialogs, sheets,
129
+ * and popovers — the container arrives first, then content fades in.
130
+ */
131
+ export declare const contentCrossfade: {
132
+ readonly initial: {
133
+ readonly opacity: 0;
134
+ readonly scale: 0.96;
135
+ };
136
+ readonly animate: {
137
+ readonly opacity: 1;
138
+ readonly scale: 1;
139
+ };
140
+ readonly exit: {
141
+ readonly opacity: 0;
142
+ readonly scale: 0.98;
143
+ };
144
+ readonly transition: {
145
+ readonly duration: 0.24;
146
+ readonly ease: [number, number, number, number];
147
+ readonly delay: 0.04;
148
+ };
149
+ };
77
150
  /** Floating bars that rise from the bottom edge. */
78
151
  export declare const riseMotion: {
79
152
  readonly initial: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passui/react",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "Passui: native React controls, design tokens, presentation and appearance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -2,7 +2,7 @@ import { Sheet } from "../sheet/sheet"
2
2
  import { AnimatePresence, motion, useReducedMotion } from "motion/react"
3
3
  import { Button } from "../button/button"
4
4
  import { cn } from "../../lib/class-names"
5
- import { spring } from "../../motion/transitions"
5
+ import { contentCrossfade, spring, zoomReveal } from "../../motion/transitions"
6
6
  import { createContext, useContext, useId, type FormEvent, type ReactNode } from "react"
7
7
 
8
8
  const CloseContext = createContext<() => void>(() => {})
@@ -81,19 +81,31 @@ export function Dialog({
81
81
  initial={{
82
82
  height: reducedMotion ? "auto" : 0,
83
83
  opacity: 0,
84
+ scale: reducedMotion ? 1 : 0.97,
84
85
  }}
85
86
  animate={{
86
87
  height: "auto",
87
88
  opacity: 1,
89
+ scale: 1,
88
90
  }}
89
91
  exit={{
90
92
  height: reducedMotion ? "auto" : 0,
91
93
  opacity: 0,
94
+ scale: reducedMotion ? 1 : 0.98,
92
95
  }}
93
- transition={reducedMotion ? { duration: 0 } : spring.smooth}
96
+ transition={reducedMotion ? { duration: 0 } : spring.bouncy}
94
97
  className="overflow-hidden"
98
+ style={{ transformOrigin: "top center" }}
95
99
  >
96
- {body}
100
+ <motion.div
101
+ initial={reducedMotion ? false : contentCrossfade.initial}
102
+ animate={reducedMotion ? undefined : contentCrossfade.animate}
103
+ transition={
104
+ reducedMotion ? { duration: 0 } : contentCrossfade.transition
105
+ }
106
+ >
107
+ {body}
108
+ </motion.div>
97
109
  </motion.div>
98
110
  )}
99
111
  </AnimatePresence>
@@ -10,7 +10,12 @@ import { concealMenuSource } from "../context-menu/conceal-menu-source"
10
10
  import { menuSurfaceExit, menuSurfaceReveal } from "../context-menu/menu-reveal"
11
11
  import { cn } from "../../lib/class-names"
12
12
  import { screenEdges } from "../../lib/screen-edges"
13
- import { floatingPanelMotion, spring } from "../../motion/transitions"
13
+ import {
14
+ contentCrossfade,
15
+ floatingPanelMotion,
16
+ fluidOvershoot,
17
+ spring,
18
+ } from "../../motion/transitions"
14
19
  import { fitPopover, type PopoverFit } from "./popover-fit"
15
20
 
16
21
  export type PopoverOrigin = "top-left" | "top-right"
@@ -56,6 +61,11 @@ function OpenPopover({
56
61
  const [reveal, setReveal] = useState<ReturnType<typeof menuSurfaceReveal> | null>(null)
57
62
  const popoverMotion = floatingPanelMotion(fit.above ? "above" : "below")
58
63
 
64
+ // For anchored popovers, the surface morphs from the trigger via
65
+ // menuSurfaceReveal. For non-anchored popovers, we use scale + the
66
+ // fluid overshoot spring so the panel grows from the origin corner
67
+ // with a physical bounce — Apple's matched-geometry feel.
68
+
59
69
  useLayoutEffect(() => {
60
70
  const refit = () => {
61
71
  const element = ref.current
@@ -283,11 +293,17 @@ function OpenPopover({
283
293
  transition={
284
294
  reducedMotion
285
295
  ? { duration: 0 }
286
- : { ...spring.smooth, opacity: { duration: 0.16 } }
296
+ : { ...fluidOvershoot, opacity: { duration: 0.16 } }
287
297
  }
288
298
  />
289
299
  )}
290
- {children}
300
+ <motion.div
301
+ initial={reducedMotion ? false : anchorRef ? { opacity: 0 } : undefined}
302
+ animate={reducedMotion ? undefined : anchorRef ? { opacity: 1 } : undefined}
303
+ transition={reducedMotion ? { duration: 0 } : { duration: 0.16, delay: 0.06 }}
304
+ >
305
+ {children}
306
+ </motion.div>
291
307
  </motion.div>
292
308
  )
293
309
  }
@@ -14,6 +14,8 @@ export interface SegmentedControlProps<T extends string> {
14
14
  onChange: (value: NoInfer<T>) => void
15
15
  /** Accessible name for the group and its compact picker. */
16
16
  label: string
17
+ /** Custom classes for the control container. */
18
+ className?: string
17
19
  }
18
20
 
19
21
  /**
@@ -27,6 +29,7 @@ export function SegmentedControl<T extends string>({
27
29
  options,
28
30
  onChange,
29
31
  label,
32
+ className,
30
33
  }: SegmentedControlProps<T>) {
31
34
  const id = useId()
32
35
  const reducedMotion = useReducedMotion()
@@ -47,7 +50,7 @@ export function SegmentedControl<T extends string>({
47
50
  }, [])
48
51
 
49
52
  return (
50
- <div ref={frame} className="relative w-full min-w-0">
53
+ <div ref={frame} className={cn("relative w-full min-w-0", className)}>
51
54
  {/* The control at its natural size, to know whether it fits. */}
52
55
  <div
53
56
  ref={measure}
@@ -2,7 +2,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"
2
2
  import { type ReactNode, useRef } from "react"
3
3
  import { useVisualViewport } from "../../hooks/use-visual-viewport"
4
4
  import { cn } from "../../lib/class-names"
5
- import { spring } from "../../motion/transitions"
5
+ import { contentCrossfade, spring, zoomReveal } from "../../motion/transitions"
6
6
  import { SheetPortal } from "./sheet-portal"
7
7
  import { useSheetFocus } from "./use-sheet-focus"
8
8
 
@@ -95,12 +95,47 @@ function OpenSheet({
95
95
  placement === "center" ? "rounded-sheet" : "rounded-t-sheet sm:rounded-sheet",
96
96
  className
97
97
  )}
98
- initial={reducedMotion ? { opacity: 0 } : { y: 40, opacity: 0 }}
99
- animate={{ y: 0, opacity: 1 }}
100
- exit={reducedMotion ? { opacity: 0 } : { y: 30, opacity: 0 }}
101
- transition={reducedMotion ? { duration: 0 } : spring.smooth}
98
+ style={{
99
+ transformOrigin: placement === "center" ? "center center" : "bottom center",
100
+ }}
101
+ initial={
102
+ reducedMotion
103
+ ? { opacity: 0 }
104
+ : placement === "center"
105
+ ? zoomReveal.initial
106
+ : { y: 40, opacity: 0, scale: 0.96 }
107
+ }
108
+ animate={
109
+ reducedMotion
110
+ ? { opacity: 1 }
111
+ : placement === "center"
112
+ ? zoomReveal.animate
113
+ : { y: 0, opacity: 1, scale: 1 }
114
+ }
115
+ exit={
116
+ reducedMotion
117
+ ? { opacity: 0 }
118
+ : placement === "center"
119
+ ? zoomReveal.exit
120
+ : { y: 30, opacity: 0, scale: 0.98 }
121
+ }
122
+ transition={
123
+ reducedMotion
124
+ ? { duration: 0 }
125
+ : placement === "center"
126
+ ? zoomReveal.transition
127
+ : { ...spring.bouncy, opacity: { duration: 0.18 } }
128
+ }
102
129
  >
103
- {children}
130
+ <motion.div
131
+ className="flex min-h-0 flex-1 flex-col overflow-y-auto"
132
+ initial={reducedMotion ? false : contentCrossfade.initial}
133
+ animate={reducedMotion ? undefined : contentCrossfade.animate}
134
+ exit={reducedMotion ? undefined : contentCrossfade.exit}
135
+ transition={reducedMotion ? { duration: 0 } : contentCrossfade.transition}
136
+ >
137
+ {children}
138
+ </motion.div>
104
139
  </motion.div>
105
140
  </div>
106
141
  )
@@ -13,10 +13,20 @@ export interface StatTileProps {
13
13
  /** Makes the tile a toggle, e.g. a filter. */
14
14
  onClick?: () => void
15
15
  active?: boolean
16
+ /** Custom classes for the tile container. */
17
+ className?: string
16
18
  }
17
19
 
18
20
  /** One number with its label, optionally a toggle. */
19
- export function StatTile({ label, value, caption, tone, onClick, active = false }: StatTileProps) {
21
+ export function StatTile({
22
+ label,
23
+ value,
24
+ caption,
25
+ tone,
26
+ onClick,
27
+ active = false,
28
+ className,
29
+ }: StatTileProps) {
20
30
  const body = (
21
31
  <>
22
32
  <span className="text-label-secondary">{label}</span>
@@ -26,17 +36,19 @@ export function StatTile({ label, value, caption, tone, onClick, active = false
26
36
  {caption && <span className="text-label-secondary">{caption}</span>}
27
37
  </>
28
38
  )
29
- const className =
30
- "flex min-w-0 flex-col gap-2 rounded-2xl border border-separator/35 bg-elevated p-4 text-left shadow-xs"
39
+ const tileClass = cn(
40
+ "flex min-w-0 flex-col gap-2 rounded-2xl border border-separator/35 bg-elevated p-4 text-left shadow-xs",
41
+ className
42
+ )
31
43
 
32
- if (!onClick) return <div className={className}>{body}</div>
44
+ if (!onClick) return <div className={tileClass}>{body}</div>
33
45
  return (
34
46
  <button
35
47
  type="button"
36
48
  aria-pressed={active}
37
49
  onClick={onClick}
38
50
  className={cn(
39
- className,
51
+ tileClass,
40
52
  "ring-1 ring-transparent transition-colors hover:bg-fill/60",
41
53
  active && "bg-fill/70 ring-label/15"
42
54
  )}
@@ -20,6 +20,8 @@ export interface SwatchPickerProps<T extends string> {
20
20
  onChange: (value: T) => void
21
21
  /** Accessible name for the group. */
22
22
  label: string
23
+ /** Custom classes for the swatch container. */
24
+ className?: string
23
25
  }
24
26
 
25
27
  /** Colour swatches with a check on the chosen one. */
@@ -28,10 +30,11 @@ export function SwatchPicker<T extends string>({
28
30
  options,
29
31
  onChange,
30
32
  label,
33
+ className,
31
34
  }: SwatchPickerProps<T>) {
32
35
  const name = useId()
33
36
  return (
34
- <fieldset className="flex flex-wrap gap-3">
37
+ <fieldset className={cn("flex flex-wrap gap-3", className)}>
35
38
  <legend className="sr-only">{label}</legend>
36
39
  {options.map((option) => {
37
40
  const selected = option.value === value
@@ -2,6 +2,7 @@ import { type ComponentProps, type ReactNode, useId, useState } from "react"
2
2
  import { Button } from "../button/button"
3
3
  import { cn } from "../../lib/class-names"
4
4
 
5
+ /** Shared text control classes for components that reuse TextField's surface. */
5
6
  export const textControlClasses = cn(
6
7
  "w-full min-w-0 rounded-xl border border-separator bg-elevated",
7
8
  "px-3 py-2 text-body text-label placeholder:text-label-tertiary",
@@ -11,7 +12,55 @@ export const textControlClasses = cn(
11
12
 
12
13
  const fieldIconClasses = "flex shrink-0 items-center text-label-secondary"
13
14
 
14
- export type TextFieldProps = ComponentProps<"input"> & {
15
+ /** Size scale shared across TextField, SecureField, and form fields. */
16
+ export type TextFieldSize = "xs" | "sm" | "md" | "lg"
17
+
18
+ interface FieldSize {
19
+ height: string
20
+ floatingHeight: string
21
+ padding: string
22
+ text: string
23
+ radius: string
24
+ }
25
+
26
+ function fieldSize(size: TextFieldSize): FieldSize {
27
+ switch (size) {
28
+ case "xs":
29
+ return {
30
+ height: "h-8",
31
+ floatingHeight: "h-9",
32
+ padding: "px-2.5",
33
+ text: "text-caption",
34
+ radius: "rounded-lg",
35
+ }
36
+ case "sm":
37
+ return {
38
+ height: "h-9",
39
+ floatingHeight: "h-10",
40
+ padding: "px-3",
41
+ text: "text-subheadline",
42
+ radius: "rounded-xl",
43
+ }
44
+ case "lg":
45
+ return {
46
+ height: "h-12",
47
+ floatingHeight: "h-14",
48
+ padding: "px-4",
49
+ text: "text-body",
50
+ radius: "rounded-xl",
51
+ }
52
+ default:
53
+ return {
54
+ height: "h-11",
55
+ floatingHeight: "h-12",
56
+ padding: "px-3",
57
+ text: "text-body",
58
+ radius: "rounded-xl",
59
+ }
60
+ }
61
+ }
62
+
63
+ export type TextFieldProps = Omit<ComponentProps<"input">, "size"> & {
15
64
  label: string
16
65
  description?: ReactNode
17
66
  errors?: string[]
@@ -24,6 +73,8 @@ export type TextFieldProps = ComponentProps<"input"> & {
24
73
  revealLabels?: { show: string; hide: string }
25
74
  /** Float the label into the outline on focus or when filled. @default "above" */
26
75
  labelPlacement?: "above" | "floating"
76
+ /** Field height and text scale. @default "md" */
77
+ size?: TextFieldSize
27
78
  }
28
79
 
29
80
  export function TextField({
@@ -35,6 +86,7 @@ export function TextField({
35
86
  action,
36
87
  revealLabels,
37
88
  labelPlacement = "above",
89
+ size = "md",
38
90
  placeholder,
39
91
  type = "text",
40
92
  id: suppliedId,
@@ -46,6 +98,7 @@ export function TextField({
46
98
  const [revealed, setRevealed] = useState(false)
47
99
  const messages = Array.from(new Set(errors))
48
100
  const reveal = type === "password" && revealLabels
101
+ const dims = fieldSize(size)
49
102
  return (
50
103
  <div className="not-typeset flex flex-col gap-2">
51
104
  {labelPlacement === "above" && (
@@ -55,7 +108,7 @@ export function TextField({
55
108
  )}
56
109
  <div
57
110
  className={cn(
58
- "relative flex items-center gap-2 rounded-xl bg-elevated px-3",
111
+ `relative flex items-center gap-2 ${dims.padding} ${dims.radius} bg-elevated`,
59
112
  labelPlacement === "floating"
60
113
  ? "passui-floating-field"
61
114
  : "border border-separator focus-within:ring-1 focus-within:ring-accent/40 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent-text/70"
@@ -99,8 +152,9 @@ export function TextField({
99
152
  .join(" ") || undefined
100
153
  }
101
154
  className={cn(
102
- "h-11 w-full min-w-0 bg-transparent text-body text-label outline-none",
103
- labelPlacement === "floating" && "h-12",
155
+ labelPlacement === "floating" ? dims.floatingHeight : dims.height,
156
+ "w-full min-w-0 bg-transparent text-label outline-none",
157
+ dims.text,
104
158
  "placeholder:text-label-tertiary disabled:opacity-40",
105
159
  className
106
160
  )}
@@ -28,6 +28,8 @@ export interface ToggleProps {
28
28
  mixed?: boolean
29
29
  /** Keeps the accessible name while hiding the visible label. @default false */
30
30
  labelHidden?: boolean
31
+ /** Custom classes for the toggle container. */
32
+ className?: string
31
33
  }
32
34
 
33
35
  export function Toggle({
@@ -38,6 +40,7 @@ export function Toggle({
38
40
  style = "switch",
39
41
  mixed = false,
40
42
  labelHidden = false,
43
+ className,
41
44
  }: ToggleProps) {
42
45
  const input = useRef<HTMLInputElement>(null)
43
46
  useEffect(() => {
@@ -48,7 +51,8 @@ export function Toggle({
48
51
  <label
49
52
  className={cn(
50
53
  "flex cursor-pointer items-center gap-3 text-subheadline",
51
- disabled && "cursor-not-allowed opacity-40"
54
+ disabled && "cursor-not-allowed opacity-40",
55
+ className
52
56
  )}
53
57
  >
54
58
  <span className="relative inline-flex size-5 shrink-0">
@@ -82,7 +86,15 @@ export function Toggle({
82
86
  </label>
83
87
  )
84
88
  }
85
- return <SwitchToggle checked={checked} onChange={onChange} label={label} disabled={disabled} />
89
+ return (
90
+ <SwitchToggle
91
+ checked={checked}
92
+ onChange={onChange}
93
+ label={label}
94
+ disabled={disabled}
95
+ className={className}
96
+ />
97
+ )
86
98
  }
87
99
 
88
100
  function SwitchToggle({
@@ -90,7 +102,8 @@ function SwitchToggle({
90
102
  onChange,
91
103
  label,
92
104
  disabled,
93
- }: Pick<ToggleProps, "checked" | "onChange" | "label" | "disabled">) {
105
+ className,
106
+ }: Pick<ToggleProps, "checked" | "onChange" | "label" | "disabled" | "className">) {
94
107
  const reducedMotion = useReducedMotion()
95
108
  const id = `toggle-goo-${useId().replace(/:/g, "")}`
96
109
  const x = useMotionValue(checked ? 22 : 2)
@@ -163,7 +176,8 @@ function SwitchToggle({
163
176
  className={cn(
164
177
  "relative h-[30px] w-[50px] shrink-0 touch-pan-y rounded-full transition-colors duration-normal motion-reduce:transition-none",
165
178
  "shadow-[inset_0_1px_2px_rgb(0_0_0/0.10)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:opacity-45",
166
- checked ? "bg-accent" : "bg-separator/80"
179
+ checked ? "bg-accent" : "bg-separator/80",
180
+ className
167
181
  )}
168
182
  >
169
183
  <svg aria-hidden="true" width="0" height="0" className="absolute">
@@ -8,18 +8,45 @@ export const spring = {
8
8
  bouncy: { type: "spring", ...springs.bouncy },
9
9
  } as const satisfies Record<keyof typeof springs, Transition>
10
10
 
11
+ /**
12
+ * Apple's fluid overshoot: cubic-bezier(0.34, 1.56, 0.1, 1).
13
+ * Replaces traditional ease profiles with an authentic fluid bounce
14
+ * that overshoots and settles, matching Apple's custom interface physics.
15
+ * Used for zoom/matched-geometry transitions where the surface emerges
16
+ * from the trigger and snaps into place with a physical bounce.
17
+ */
18
+ export const fluidOvershoot = {
19
+ duration: 0.4,
20
+ ease: [0.34, 1.56, 0.1, 1] as [number, number, number, number],
21
+ } as const
22
+
23
+ /**
24
+ * Exit motion: content collapses back toward its source.
25
+ * The surface shrinks toward the trigger's position while fading,
26
+ * like a water drop absorbing back into its origin.
27
+ */
28
+ export const fluidCollapse = {
29
+ duration: 0.28,
30
+ ease: [0.4, 0, 0.6, 1] as [number, number, number, number],
31
+ } as const
32
+
11
33
  export type FloatingPanelSide = "above" | "below"
12
34
 
13
- /** Anchored panels settle toward their source without shrinking their text. */
35
+ /**
36
+ * Anchored panels settle toward their source without shrinking their text.
37
+ * Now uses origin-based scaling: the panel grows from the trigger's
38
+ * position (transform-origin set by the caller) with a scale + opacity
39
+ * crossfade, rather than a plain slide.
40
+ */
14
41
  export function floatingPanelMotion(side: FloatingPanelSide = "below") {
15
42
  const direction = side === "above" ? 1 : -1
16
43
  return {
17
- initial: { opacity: 0, scale: 1, y: direction * 6 },
44
+ initial: { opacity: 0, scale: 0.92, y: direction * 6 },
18
45
  animate: { opacity: 1, scale: 1, y: 0 },
19
- exit: { opacity: 0, scale: 1, y: direction * 4 },
46
+ exit: { opacity: 0, scale: 0.95, y: direction * 4 },
20
47
  transition: {
21
- ...spring.smooth,
22
- opacity: { duration: 0.16 },
48
+ ...fluidOvershoot,
49
+ opacity: { duration: 0.16, ease: [0.4, 0, 0.2, 1] as [number, number, number, number] },
23
50
  },
24
51
  } as const
25
52
  }
@@ -27,6 +54,37 @@ export function floatingPanelMotion(side: FloatingPanelSide = "below") {
27
54
  /** Default below-anchor presentation, retained for existing consumers. */
28
55
  export const popoverMotion = floatingPanelMotion()
29
56
 
57
+ /**
58
+ * Zoom/matched-geometry transition for dialogs and center-placed sheets.
59
+ * The surface starts small (scale 0.88) and invisible, then grows to
60
+ * full size with the fluid overshoot bounce. Content elements inside
61
+ * crossfade in slightly delayed so they phase into visibility
62
+ * asynchronously — not a harsh switch.
63
+ */
64
+ export const zoomReveal = {
65
+ initial: { opacity: 0, scale: 0.88 },
66
+ animate: { opacity: 1, scale: 1 },
67
+ exit: { opacity: 0, scale: 0.92 },
68
+ transition: fluidOvershoot,
69
+ } as const
70
+
71
+ /**
72
+ * Content crossfade for elements inside an overlay.
73
+ * Rather than a harsh switch, sub-elements scale and phase into
74
+ * visibility asynchronously. Use for children of dialogs, sheets,
75
+ * and popovers — the container arrives first, then content fades in.
76
+ */
77
+ export const contentCrossfade = {
78
+ initial: { opacity: 0, scale: 0.96 },
79
+ animate: { opacity: 1, scale: 1 },
80
+ exit: { opacity: 0, scale: 0.98 },
81
+ transition: {
82
+ duration: 0.24,
83
+ ease: [0.4, 0, 0.2, 1] as [number, number, number, number],
84
+ delay: 0.04,
85
+ },
86
+ } as const
87
+
30
88
  /** Floating bars that rise from the bottom edge. */
31
89
  export const riseMotion = {
32
90
  initial: { opacity: 0, y: 32 },