framer-code-link 0.7.0 → 0.8.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.
@@ -0,0 +1,869 @@
1
+ # Framer Component Examples
2
+
3
+ Reference implementations demonstrating best practices.
4
+
5
+ ## Cookie Banner
6
+
7
+ Location-aware cookie consent with timezone detection.
8
+
9
+ ```tsx
10
+ // Cookie banner with opt-in for Europe, opt-out elsewhere, based on time zone
11
+ import {
12
+ useEffect,
13
+ useState,
14
+ startTransition,
15
+ type CSSProperties,
16
+ } from "react";
17
+ import { addPropertyControls, ControlType, RenderTarget } from "framer";
18
+
19
+ interface CookiebannerProps {
20
+ message: string;
21
+ acceptLabel: string;
22
+ declineLabel: string;
23
+ backgroundColor: string;
24
+ textColor: string;
25
+ buttonColor: string;
26
+ buttonTextColor: string;
27
+ font: any;
28
+ borderRadius: number;
29
+ buttonFont: any;
30
+ style?: CSSProperties;
31
+ }
32
+
33
+ /**
34
+ * Cookies
35
+ *
36
+ * @framerIntrinsicWidth 400
37
+ * @framerIntrinsicHeight 100
38
+ *
39
+ * @framerSupportedLayoutWidth any-prefer-fixed
40
+ * @framerSupportedLayoutHeight any-prefer-fixed
41
+ */
42
+ export default function Cookiebanner(props: CookiebannerProps) {
43
+ const {
44
+ message,
45
+ acceptLabel,
46
+ declineLabel,
47
+ backgroundColor,
48
+ textColor,
49
+ buttonColor,
50
+ buttonTextColor,
51
+ font,
52
+ borderRadius,
53
+ } = props;
54
+
55
+ // Guess if user is in Europe based on timezone offset
56
+ const [show, setShow] = useState(true);
57
+ const [isEurope, setIsEurope] = useState(false);
58
+ useEffect(() => {
59
+ if (typeof window !== "undefined") {
60
+ const offset = new Date().getTimezoneOffset();
61
+ // Europe: UTC+0 to UTC+3 (offset -0 to -180)
62
+ startTransition(() => setIsEurope(offset <= 0 && offset >= -180));
63
+ }
64
+ }, []);
65
+
66
+ // Hide on accept/decline
67
+ function handleAccept() {
68
+ startTransition(() => setShow(false));
69
+ }
70
+ function handleDecline() {
71
+ startTransition(() => setShow(false));
72
+ }
73
+
74
+ if (!show || RenderTarget.current() === RenderTarget.thumbnail) return null;
75
+
76
+ const buttonBaseStyles = {
77
+ borderRadius: 10,
78
+ flex: 1,
79
+ border: `1px solid ${buttonColor}`,
80
+ padding: "8px 18px",
81
+ cursor: "pointer",
82
+ ...props.buttonFont,
83
+ };
84
+
85
+ const isFixedWidth = props?.style && props.style.width === "100%";
86
+
87
+ return (
88
+ <div
89
+ style={{
90
+ ...props.style,
91
+ overflow: "hidden",
92
+ position: "relative",
93
+ ...(isFixedWidth ? { ...props?.style } : { minWidth: "max-content" }),
94
+ background: backgroundColor,
95
+ color: textColor,
96
+ borderRadius,
97
+ display: "flex",
98
+ flexDirection: "column",
99
+ justifyContent: "space-between",
100
+ padding: 20,
101
+ boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
102
+ gap: 20,
103
+
104
+ ...props.font,
105
+ }}
106
+ >
107
+ <span style={{ flex: 1 }}>{message}</span>
108
+ <div style={{ width: "100%", display: "flex", gap: 10 }}>
109
+ <button
110
+ style={{
111
+ ...buttonBaseStyles,
112
+ background: "transparent",
113
+ color: buttonColor,
114
+ }}
115
+ onClick={handleDecline}
116
+ >
117
+ {declineLabel}
118
+ </button>
119
+ <button
120
+ style={{
121
+ ...buttonBaseStyles,
122
+ background: buttonColor,
123
+ color: buttonTextColor,
124
+ }}
125
+ onClick={handleAccept}
126
+ >
127
+ {acceptLabel}
128
+ </button>
129
+ </div>
130
+ </div>
131
+ );
132
+ }
133
+
134
+ addPropertyControls(Cookiebanner, {
135
+ message: {
136
+ type: ControlType.String,
137
+ title: "Message",
138
+ defaultValue: "We use cookies to improve your website experience.",
139
+ displayTextArea: true,
140
+ },
141
+ acceptLabel: {
142
+ type: ControlType.String,
143
+ title: "Accept Label",
144
+ defaultValue: "Accept",
145
+ },
146
+ declineLabel: {
147
+ type: ControlType.String,
148
+ title: "Decline Label",
149
+ defaultValue: "Decline",
150
+ },
151
+ backgroundColor: {
152
+ type: ControlType.Color,
153
+ title: "Background",
154
+ defaultValue: "#fff",
155
+ },
156
+ textColor: {
157
+ type: ControlType.Color,
158
+ title: "Text Color",
159
+ defaultValue: "#222",
160
+ },
161
+ buttonColor: {
162
+ type: ControlType.Color,
163
+ title: "Button Color",
164
+ defaultValue: "#111",
165
+ },
166
+ buttonTextColor: {
167
+ type: ControlType.Color,
168
+ title: "Button Text",
169
+ defaultValue: "#fff",
170
+ },
171
+ font: {
172
+ type: ControlType.Font,
173
+ title: "Font",
174
+ controls: "extended",
175
+ defaultFontType: "sans-serif",
176
+ defaultValue: {
177
+ variant: "Medium",
178
+ fontSize: "14px",
179
+ letterSpacing: "-0.01em",
180
+ lineHeight: "1em",
181
+ },
182
+ },
183
+ buttonFont: {
184
+ type: ControlType.Font,
185
+ title: "Font",
186
+ controls: "extended",
187
+ defaultFontType: "sans-serif",
188
+ defaultValue: {
189
+ variant: "Medium",
190
+ fontSize: "14px",
191
+ letterSpacing: "-0.01em",
192
+ lineHeight: "1em",
193
+ },
194
+ },
195
+ borderRadius: {
196
+ type: ControlType.Number,
197
+ title: "Radius",
198
+ defaultValue: 8,
199
+ min: 0,
200
+ max: 32,
201
+ },
202
+ });
203
+ ```
204
+
205
+ ## Tweemoji
206
+
207
+ Convert emoji to Twitter's Twemoji SVGs.
208
+
209
+ ````tsx
210
+ import { useMemo, useEffect, useState, type CSSProperties } from "react";
211
+ import { addPropertyControls, ControlType, withCSS } from "framer";
212
+ import twemojiParser from "https://jspm.dev/twemoji-parser@14.0.0";
213
+
214
+ const fireSrc =
215
+ "https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/1f525.svg";
216
+
217
+ interface TwemojiProps {
218
+ /** Emoji to convert such as 🍐, 🐙 or 🐸 */
219
+ search?: string;
220
+ isSelection?: boolean;
221
+ [prop: string]: any;
222
+ }
223
+
224
+ const baseURL = "https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/";
225
+
226
+ /**
227
+ * TWEMOJI
228
+ *
229
+ * Convert any emoji into a Twemoji from Twitter. Choose a preset or type in your emoji and the Twemoji will automatically appear on the canvas.
230
+ *
231
+ * ```jsx
232
+ * <Twemoji search="🍐" />
233
+ * ```
234
+ *
235
+ * @framerIntrinsicWidth 100
236
+ * @framerIntrinsicHeight 100
237
+ *
238
+ * @framerSupportedLayoutWidth fixed
239
+ * @framerSupportedLayoutHeight fixed
240
+ */
241
+ export default function Twemoji(props: TwemojiProps) {
242
+ const { search, isSelection, selection, style, alt = "" } = props;
243
+
244
+ const emoji = useMemo(() => {
245
+ if (isSelection) return selection;
246
+ if (!search) return "⭐️";
247
+ return search;
248
+ }, [search, isSelection, selection]);
249
+
250
+ const src = useMemo(() => {
251
+ const parsedTwemoji = twemojiParser.parse(emoji, {
252
+ buildUrl: (icon) => `${baseURL}${icon}.svg`,
253
+ });
254
+ return parsedTwemoji[0].url;
255
+ }, [emoji]);
256
+
257
+ return (
258
+ <div style={containerStyle}>
259
+ <img src={src} style={containerStyle} alt={alt} />
260
+ </div>
261
+ );
262
+ }
263
+
264
+ addPropertyControls<TwemojiProps>(Twemoji, {
265
+ isSelection: {
266
+ type: ControlType.Boolean,
267
+ title: "Select",
268
+ enabledTitle: "Preset",
269
+ disabledTitle: "Search",
270
+ },
271
+ selection: {
272
+ type: ControlType.Enum,
273
+ title: " ",
274
+ options: ["🔥", "💖", "😆", "👍", "👎"],
275
+ defaultValue: "🔥",
276
+ displaySegmentedControl: true,
277
+ hidden: ({ isSelection }) => !isSelection,
278
+ },
279
+ search: {
280
+ type: ControlType.String,
281
+ title: " ",
282
+ placeholder: "Paste Emoji…",
283
+ defaultValue: "⭐️",
284
+ hidden: ({ isSelection }) => isSelection,
285
+ },
286
+ });
287
+
288
+ const containerStyle: CSSProperties = {
289
+ height: "100%",
290
+ width: "100%",
291
+ objectFit: "contain",
292
+ textAlign: "center",
293
+ overflow: "hidden",
294
+ backgroundColor: "transparent",
295
+ };
296
+ ````
297
+
298
+ ## Image Compare
299
+
300
+ Before/after image comparison slider.
301
+
302
+ ```tsx
303
+ import {
304
+ addPropertyControls,
305
+ ControlType,
306
+ RenderTarget,
307
+ useIsStaticRenderer,
308
+ } from "framer";
309
+ import {
310
+ useCallback,
311
+ useEffect,
312
+ useRef,
313
+ useState,
314
+ startTransition,
315
+ type CSSProperties,
316
+ } from "react";
317
+
318
+ interface Image {
319
+ src: string;
320
+ alt: string;
321
+ }
322
+
323
+ interface ImageCompareProps {
324
+ beforeImage: Image;
325
+ afterImage: Image;
326
+ orientation: "horizontal" | "vertical";
327
+ initialPosition: number;
328
+ dividerColor: string;
329
+ dividerWidth: number;
330
+ dividerShadow: boolean;
331
+ showHandle: boolean;
332
+ handleColor: string;
333
+ handleSize: number;
334
+ style?: CSSProperties;
335
+ }
336
+
337
+ /**
338
+ * Image Comparison Slider
339
+ *
340
+ * A component that allows users to compare two images by dragging a divider.
341
+ *
342
+ * @framerIntrinsicWidth 500
343
+ * @framerIntrinsicHeight 300
344
+ *
345
+ * @framerSupportedLayoutWidth fixed
346
+ * @framerSupportedLayoutHeight fixed
347
+ */
348
+ export default function ImageCompare(props: ImageCompareProps) {
349
+ const {
350
+ beforeImage = {
351
+ src: "https://framerusercontent.com/images/GfGkADagM4KEibNcIiRUWlfrR0.jpg",
352
+ alt: "Before image",
353
+ },
354
+ afterImage = {
355
+ src: "https://framerusercontent.com/images/aNsAT3jCvt4zglbWCUoFe33Q.jpg",
356
+ alt: "After image",
357
+ },
358
+ orientation = "horizontal",
359
+ initialPosition = 50,
360
+ dividerColor = "#FFFFFF",
361
+ dividerWidth = 2,
362
+ dividerShadow = true,
363
+ showHandle = false,
364
+ handleColor = "#FFFFFF",
365
+ handleSize = 40,
366
+ } = props;
367
+
368
+ const isHorizontal = orientation === "horizontal";
369
+ const containerRef = useRef<HTMLDivElement>(null);
370
+ const [position, setPosition] = useState(initialPosition);
371
+ const [isDragging, setIsDragging] = useState(false);
372
+ const isStatic = useIsStaticRenderer();
373
+
374
+ const updatePositionFromEvent = useCallback(
375
+ (e) => {
376
+ if (!containerRef.current) return;
377
+
378
+ const rect = containerRef.current.getBoundingClientRect();
379
+
380
+ if (isHorizontal) {
381
+ const x = e.clientX - rect.left;
382
+ const newPosition = Math.max(0, Math.min(100, (x / rect.width) * 100));
383
+ startTransition(() => setPosition(newPosition));
384
+ } else {
385
+ const y = e.clientY - rect.top;
386
+ const newPosition = Math.max(0, Math.min(100, (y / rect.height) * 100));
387
+ startTransition(() => setPosition(newPosition));
388
+ }
389
+ },
390
+ [isHorizontal],
391
+ );
392
+
393
+ const handleClick = useCallback(
394
+ (e) => {
395
+ // Only handle as a click if we're not dragging
396
+ if (!isDragging) {
397
+ updatePositionFromEvent(e);
398
+ }
399
+ },
400
+ [isDragging, updatePositionFromEvent],
401
+ );
402
+
403
+ const handleDoubleClick = () => {
404
+ startTransition(() => setPosition(initialPosition));
405
+ };
406
+
407
+ const handleMouseDown = (e) => {
408
+ e.preventDefault();
409
+ startTransition(() => setIsDragging(true));
410
+ };
411
+
412
+ const handleMouseMove = useCallback(
413
+ (e) => {
414
+ if (!isDragging || !containerRef.current) return;
415
+ updatePositionFromEvent(e);
416
+ },
417
+ [isDragging, updatePositionFromEvent],
418
+ );
419
+
420
+ const handleMouseUp = useCallback(() => {
421
+ startTransition(() => setIsDragging(false));
422
+ }, []);
423
+
424
+ // Add global event listeners for drag
425
+ useEffect(() => {
426
+ if (isStatic) return;
427
+
428
+ const handleGlobalMouseMove = (e) => handleMouseMove(e);
429
+ const handleGlobalMouseUp = () => handleMouseUp();
430
+
431
+ if (isDragging) {
432
+ window.addEventListener("mousemove", handleGlobalMouseMove);
433
+ window.addEventListener("mouseup", handleGlobalMouseUp);
434
+ }
435
+
436
+ return () => {
437
+ window.removeEventListener("mousemove", handleGlobalMouseMove);
438
+ window.removeEventListener("mouseup", handleGlobalMouseUp);
439
+ };
440
+ }, [isDragging, handleMouseMove, handleMouseUp, isStatic]);
441
+
442
+ return (
443
+ <div
444
+ ref={containerRef}
445
+ style={{
446
+ position: "relative",
447
+ width: "100%",
448
+ height: "100%",
449
+ overflow: "hidden",
450
+ cursor: isDragging
451
+ ? isHorizontal
452
+ ? "ew-resize"
453
+ : "ns-resize"
454
+ : "pointer",
455
+ userSelect: "none",
456
+ }}
457
+ onClick={isStatic ? undefined : handleClick}
458
+ onMouseMove={isStatic ? undefined : handleMouseMove}
459
+ onMouseDown={isStatic ? undefined : handleMouseDown}
460
+ onMouseUp={isStatic ? undefined : handleMouseUp}
461
+ onDoubleClick={handleDoubleClick}
462
+ onKeyDown={(e) => {
463
+ if (e.key === " " || e.key === "Enter") {
464
+ handleClick(e);
465
+ }
466
+ }}
467
+ tabIndex={0}
468
+ role="slider"
469
+ aria-valuenow={position}
470
+ aria-valuemin={0}
471
+ aria-valuemax={100}
472
+ aria-orientation={orientation}
473
+ >
474
+ {/* After Image (Full) */}
475
+ <div
476
+ style={{
477
+ position: "absolute",
478
+ top: 0,
479
+ left: 0,
480
+ width: "100%",
481
+ height: "100%",
482
+ backgroundImage: `url(${afterImage.src})`,
483
+ backgroundSize: "cover",
484
+ backgroundPosition: "center",
485
+ }}
486
+ aria-label={afterImage.alt}
487
+ role="img"
488
+ />
489
+
490
+ {/* Before Image (Clipped) */}
491
+ <div
492
+ style={{
493
+ position: "absolute",
494
+ top: 0,
495
+ left: 0,
496
+ width: "100%",
497
+ height: "100%",
498
+ backgroundImage: `url(${beforeImage.src})`,
499
+ backgroundSize: "cover",
500
+ backgroundPosition: "center",
501
+ clipPath: isHorizontal
502
+ ? `inset(0 ${100 - position}% 0 0)`
503
+ : `inset(0 0 ${100 - position}% 0)`,
504
+ }}
505
+ aria-label={beforeImage.alt}
506
+ role="img"
507
+ />
508
+
509
+ {/* Divider */}
510
+ <div
511
+ style={{
512
+ position: "absolute",
513
+ top: isHorizontal ? 0 : `${position}%`,
514
+ left: isHorizontal ? `${position}%` : 0,
515
+ width: isHorizontal ? `${dividerWidth}px` : "100%",
516
+ height: isHorizontal ? "100%" : `${dividerWidth}px`,
517
+ backgroundColor: dividerColor,
518
+ boxShadow: dividerShadow ? "0 0 5px rgba(0, 0, 0, 0.7)" : "none",
519
+ transform: isHorizontal
520
+ ? `translateX(-${dividerWidth / 2}px)`
521
+ : `translateY(-${dividerWidth / 2}px)`,
522
+ cursor: isHorizontal ? "ew-resize" : "ns-resize",
523
+ zIndex: 2,
524
+ }}
525
+ onMouseDown={isStatic ? undefined : handleMouseDown}
526
+ />
527
+
528
+ {/* Handle */}
529
+ {showHandle && (
530
+ <div
531
+ style={{
532
+ position: "absolute",
533
+ top: isHorizontal
534
+ ? `calc(50% - ${handleSize / 2}px)`
535
+ : `${position}%`,
536
+ left: isHorizontal
537
+ ? `${position}%`
538
+ : `calc(50% - ${handleSize / 2}px)`,
539
+ width: `${handleSize}px`,
540
+ height: `${handleSize}px`,
541
+ borderRadius: "50%",
542
+ backgroundColor: handleColor,
543
+ border: `2px solid ${handleColor}`,
544
+ boxShadow: "0 0 5px rgba(0, 0, 0, 0.5)",
545
+ transform: isHorizontal
546
+ ? `translateX(-${handleSize / 2}px)`
547
+ : `translateY(-${handleSize / 2}px)`,
548
+ cursor: isHorizontal ? "ew-resize" : "ns-resize",
549
+ zIndex: 3,
550
+ display: "flex",
551
+ justifyContent: "center",
552
+ alignItems: "center",
553
+ }}
554
+ onMouseDown={isStatic ? undefined : handleMouseDown}
555
+ >
556
+ <div
557
+ style={{
558
+ display: "flex",
559
+ justifyContent: "center",
560
+ alignItems: "center",
561
+ width: "100%",
562
+ height: "100%",
563
+ transform: isHorizontal ? "rotate(90deg)" : "rotate(0deg)",
564
+ }}
565
+ >
566
+ <svg
567
+ viewBox="0 0 24 24"
568
+ width={handleSize * 0.5}
569
+ height={handleSize * 0.5}
570
+ strokeWidth="2"
571
+ stroke="#000"
572
+ fill="none"
573
+ aria-label="Drag handle"
574
+ >
575
+ <title>Drag handle</title>
576
+ <path d="M13 5l6 6m-6 6l6-6m-6 0l-6 6m6-6l-6-6" />
577
+ </svg>
578
+ </div>
579
+ </div>
580
+ )}
581
+ </div>
582
+ );
583
+ }
584
+
585
+ addPropertyControls(ImageCompare, {
586
+ beforeImage: {
587
+ type: ControlType.ResponsiveImage,
588
+ title: "Before Image",
589
+ },
590
+ afterImage: {
591
+ type: ControlType.ResponsiveImage,
592
+ title: "After Image",
593
+ },
594
+ orientation: {
595
+ type: ControlType.Enum,
596
+ title: "Orientation",
597
+ options: ["horizontal", "vertical"],
598
+ optionTitles: ["Horizontal", "Vertical"],
599
+ defaultValue: "horizontal",
600
+ displaySegmentedControl: true,
601
+ },
602
+ initialPosition: {
603
+ type: ControlType.Number,
604
+ title: "Initial Position",
605
+ defaultValue: 50,
606
+ min: 0,
607
+ max: 100,
608
+ step: 1,
609
+ unit: "%",
610
+ },
611
+ dividerColor: {
612
+ type: ControlType.Color,
613
+ title: "Divider Color",
614
+ defaultValue: "#FFFFFF",
615
+ },
616
+ dividerWidth: {
617
+ type: ControlType.Number,
618
+ title: "Divider Width",
619
+ defaultValue: 2,
620
+ min: 1,
621
+ max: 20,
622
+ step: 1,
623
+ unit: "px",
624
+ },
625
+ dividerShadow: {
626
+ type: ControlType.Boolean,
627
+ title: "Divider Shadow",
628
+ defaultValue: true,
629
+ enabledTitle: "On",
630
+ disabledTitle: "Off",
631
+ },
632
+ showHandle: {
633
+ type: ControlType.Boolean,
634
+ title: "Show Handle",
635
+ defaultValue: false,
636
+ enabledTitle: "Show",
637
+ disabledTitle: "Hide",
638
+ },
639
+ handleColor: {
640
+ type: ControlType.Color,
641
+ title: "Handle Color",
642
+ defaultValue: "#FFFFFF",
643
+ hidden: ({ showHandle }) => !showHandle,
644
+ },
645
+ handleSize: {
646
+ type: ControlType.Number,
647
+ title: "Handle Size",
648
+ defaultValue: 40,
649
+ min: 20,
650
+ max: 80,
651
+ step: 1,
652
+ unit: "px",
653
+ hidden: ({ showHandle }) => !showHandle,
654
+ },
655
+ });
656
+ ```
657
+
658
+ ## Notes (Sticky Note)
659
+
660
+ Colorful sticky note with font options.
661
+
662
+ ```tsx
663
+ import { type MouseEventHandler, type CSSProperties, useMemo } from "react";
664
+ import { addPropertyControls, ControlType, RenderTarget, Color } from "framer";
665
+
666
+ const colors = {
667
+ blue: "#0099FF",
668
+ darkBlue: "#0066FF",
669
+ purple: "#8855FF",
670
+ red: "#FF5588",
671
+ green: "#22CC66",
672
+ yellow: "#FFBB00",
673
+ };
674
+
675
+ interface NotesProps {
676
+ note: string;
677
+ shadow: boolean;
678
+ color: string;
679
+ preview: boolean;
680
+ alignment: "left" | "center" | "right";
681
+ smallFont: boolean;
682
+ onClick?: MouseEventHandler<HTMLDivElement>;
683
+ onMouseEnter?: MouseEventHandler<HTMLDivElement>;
684
+ onMouseLeave?: MouseEventHandler<HTMLDivElement>;
685
+ onMouseDown?: MouseEventHandler<HTMLDivElement>;
686
+ onMouseUp?: MouseEventHandler<HTMLDivElement>;
687
+ useScriptFont: boolean;
688
+ font: CSSProperties;
689
+ }
690
+
691
+ /**
692
+ * STICKY
693
+ *
694
+ * @framerIntrinsicWidth 150
695
+ * @framerIntrinsicHeight 150
696
+ *
697
+ * @framerSupportedLayoutWidth any-prefer-fixed
698
+ * @framerSupportedLayoutHeight any-prefer-fixed
699
+ */
700
+ export default function Notes(props: NotesProps) {
701
+ const {
702
+ note = "",
703
+ shadow,
704
+ color,
705
+ preview,
706
+ alignment,
707
+ smallFont,
708
+ onClick,
709
+ onMouseEnter,
710
+ onMouseLeave,
711
+ onMouseDown,
712
+ onMouseUp,
713
+ useScriptFont,
714
+ font,
715
+ } = props;
716
+
717
+ const [baseColorString, backgroundColorString] = useMemo(() => {
718
+ const baseColor = Color(colors[color]);
719
+ const hslColor = Color.toHsl(baseColor);
720
+ hslColor.l = 0.95;
721
+
722
+ const baseColorString = Color(colors[color]).toValue();
723
+ const backgroundColorString = Color(hslColor).toValue();
724
+
725
+ return [baseColorString, backgroundColorString];
726
+ }, [color]);
727
+
728
+ const centerAligned = alignment === "center";
729
+ const hasContent = note.length > 0;
730
+
731
+ return (
732
+ <div
733
+ style={{
734
+ flex: 1,
735
+ width: "100%",
736
+ height: "100%",
737
+ display: "flex",
738
+ alignItems: centerAligned ? "center" : "flex-start",
739
+ backgroundColor: backgroundColorString,
740
+ overflow: "hidden",
741
+ paddingLeft: smallFont ? 15 : 18,
742
+ paddingTop: useScriptFont ? 12 : 14,
743
+ paddingBottom: useScriptFont ? 12 : 14,
744
+ paddingRight: smallFont ? 15 : 18,
745
+ borderRadius: 8,
746
+ visibility:
747
+ RenderTarget.current() === RenderTarget.preview && !preview
748
+ ? "hidden"
749
+ : "visible",
750
+ ...(useScriptFont ? { fontFamily: "Nanum Pen Script" } : font),
751
+ //@ts-ignore
752
+ fontDisplay: "fallback",
753
+ boxShadow: shadow ? "0 4px 10px rgba(0,0,0,0.08)" : "none",
754
+ }}
755
+ {...{ onClick, onMouseEnter, onMouseLeave, onMouseDown, onMouseUp }}
756
+ >
757
+ {useScriptFont && (
758
+ <link
759
+ href="https://fonts.googleapis.com/css?family=Nanum+Pen+Script&display=swap"
760
+ rel="stylesheet"
761
+ />
762
+ )}
763
+ <p
764
+ style={{
765
+ width: "max-content",
766
+ wordBreak: "break-word",
767
+ overflowWrap: "break-word",
768
+ overflow: "hidden",
769
+ whiteSpace: "pre-wrap",
770
+ margin: 0,
771
+ fontSize: smallFont
772
+ ? useScriptFont
773
+ ? 18
774
+ : 12
775
+ : useScriptFont
776
+ ? 32
777
+ : 24,
778
+
779
+ lineHeight: smallFont
780
+ ? useScriptFont
781
+ ? 1.15
782
+ : 1.4
783
+ : useScriptFont
784
+ ? 1.08
785
+ : 1.3,
786
+ textAlign: alignment,
787
+ color: baseColorString,
788
+ display: "-webkit-box",
789
+ opacity: hasContent ? 1 : 0.5,
790
+ WebkitBoxOrient: "vertical",
791
+ }}
792
+ >
793
+ {hasContent ? note : "Write something..."}
794
+ </p>
795
+ </div>
796
+ );
797
+ }
798
+
799
+ addPropertyControls(Notes, {
800
+ note: {
801
+ type: ControlType.String,
802
+ displayTextArea: true,
803
+ placeholder: `Write something… \n\n\n`,
804
+ },
805
+ color: {
806
+ type: ControlType.Enum,
807
+ defaultValue: "blue",
808
+ options: Object.keys(colors),
809
+ optionTitles: Object.keys(colors).map((c) =>
810
+ c.replace(/^\w/, (c) => c.toUpperCase()),
811
+ ),
812
+ },
813
+
814
+ alignment: {
815
+ title: "Text Align",
816
+ type: ControlType.Enum,
817
+ displaySegmentedControl: true,
818
+ optionTitles: ["Left", "Center", "Right"],
819
+ options: ["left", "center", "right"],
820
+ },
821
+ useScriptFont: {
822
+ type: ControlType.Boolean,
823
+ disabledTitle: "Custom",
824
+ enabledTitle: "Script",
825
+ title: "Font",
826
+ defaultTitle: true,
827
+ },
828
+ font: {
829
+ type: ControlType.Font,
830
+ defaultFontType: "sans-serif",
831
+ controls: "extended",
832
+ hidden: ({ useScriptFont }) => useScriptFont,
833
+ },
834
+ smallFont: {
835
+ type: ControlType.Boolean,
836
+ disabledTitle: "Big",
837
+ enabledTitle: "Small",
838
+ title: "Text Size",
839
+ defaultValue: true,
840
+ },
841
+ preview: {
842
+ type: ControlType.Boolean,
843
+ defaultValue: true,
844
+ title: "In Preview",
845
+ enabledTitle: "Show",
846
+ disabledTitle: "Hide",
847
+ },
848
+ shadow: {
849
+ type: ControlType.Boolean,
850
+ defaultValue: false,
851
+ title: "Shadow",
852
+ enabledTitle: "Show",
853
+ disabledTitle: "Hide",
854
+ },
855
+ });
856
+
857
+ Notes.displayName = "Sticky Note";
858
+ ```
859
+
860
+ ## Key Patterns Demonstrated
861
+
862
+ 1. **SSR Safety**: `if (typeof window !== "undefined")` guards
863
+ 2. **State Transitions**: `setState` wrapped in `startTransition()` for smooth interactions
864
+ 3. **Static Renderer**: `useIsStaticRenderer()` to skip animations on canvas
865
+ 4. **Image Defaults**: Set in destructuring, not in property controls
866
+ 5. **Font Controls**: `controls: "extended"` with `defaultFontType: "sans-serif"` for full typography customization
867
+ 6. **Conditional Controls**: `hidden: (props) => !props.showFeature`
868
+ 7. **Accessibility**: `role`, `aria-*`, semantic HTML, keyboard support
869
+ 8. **Color Utilities**: Using `Color` from framer for color manipulation