cult-ui
Layout
Input
Display

sortable-list

An animated sortable list.

animated
button
flex
list
motion
positioning
shadcn
text
transition

Source Code

Files
demo
1"use client"
2 
3import { useCallback, useState } from "react"
4import { Plus, RepeatIcon, Settings2Icon, XIcon } from "lucide-react"
5import { AnimatePresence, LayoutGroup, motion } from "motion/react"
6import { toast } from "sonner"
7 
8import { cn } from "@/lib/utils"
9import { Button } from "@/components/ui/button"
10import { Slider } from "@/components/ui/slider"
11import { DirectionAwareTabs } from "@/components/ui/direction-aware-tabs"
12 
13import SortableList, { Item, SortableListItem } from "../ui/sortable-list"
14 
15const initialState = [
16  {
17    text: "Gather Data",
18    checked: false,
19    id: 1,
20    description:
21      "Collect relevant marketing copy from the user's website and competitor sites to understand the current market positioning and identify potential areas for improvement.",
22  },
23  {
24    text: "Analyze Copy",
25    checked: false,
26    id: 2,
27    description:
28      "As an AI language model, analyze the collected marketing copy for clarity, persuasiveness, and alignment with the user's brand voice and target audience. Identify strengths, weaknesses, and opportunities for optimization.",
29  },
30  {
31    text: "Create Suggestions",
32    checked: false,
33    id: 3,
34    description:
35      "Using natural language generation techniques, create alternative versions of the marketing copy that address the identified weaknesses and leverage the opportunities for improvement. Ensure the generated copy is compelling, on-brand, and optimized for the target audience.",
36  },
37  {
38    text: "Recommendations",
39    checked: false,
40    id: 5,
41    description:
42      "Present the AI-generated marketing copy suggestions to the user, along with insights on why these changes were recommended. Provide a user-friendly interface for the user to review, edit, and implement the optimized copy on their website.",
43  },
44]
45 
46function SortableListDemo() {
47  const [items, setItems] = useState<Item[]>(initialState)
48  const [openItemId, setOpenItemId] = useState<number | null>(null)
49  const [tabChangeRerender, setTabChangeRerender] = useState<number>(1)
50  const [topP, setTopP] = useState([10])
51  const [temp, setTemp] = useState([10])
52  const [tokens, setTokens] = useState([10])
53 
54  const handleCompleteItem = (id: number) => {
55    setItems((prevItems) =>
56      prevItems.map((item) =>
57        item.id === id ? { ...item, checked: !item.checked } : item
58      )
59    )
60  }
61 
62  const handleAddItem = () => {
63    setItems((prevItems) => [
64      ...prevItems,
65      {
66        text: `Item ${prevItems.length + 1}`,
67        checked: false,
68        id: Date.now(),
69        description: "",
70      },
71    ])
72  }
73 
74  const handleResetItems = () => {
75    setItems(initialState)
76  }
77 
78  const handleCloseOnDrag = useCallback(() => {
79    setItems((prevItems) => {
80      const updatedItems = prevItems.map((item) =>
81        item.checked ? { ...item, checked: false } : item
82      )
83      return updatedItems.some(
84        (item, index) => item.checked !== prevItems[index].checked
85      )
86        ? updatedItems
87        : prevItems
88    })
89  }, [])
90 
91  const renderListItem = (
92    item: Item,
93    order: number,
94    onCompleteItem: (id: number) => void,
95    onRemoveItem: (id: number) => void
96  ) => {
97    const isOpen = item.id === openItemId
98 
99    const tabs = [
100      {
101        id: 0,
102        label: "Title",
103        content: (
104          <div className="flex w-full flex-col pr-2 py-2">
105            <motion.div
106              initial={{ opacity: 0, filter: "blur(4px)" }}
107              animate={{ opacity: 1, filter: "blur(0px)" }}
108              transition={{
109                type: "spring",
110                bounce: 0.2,
111                duration: 0.75,
112                delay: 0.15,
113              }}
114            >
115              <label className="text-xs text-neutral-400">
116                Short title for your agent task
117              </label>
118              <motion.input
119                type="text"
120                value={item.text}
121                className=" w-full rounded-lg border font-semibold border-black/10 bg-neutral-800 px-1 py-[6px] text-xl md:text-3xl text-white placeholder:text-white/30 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#13EEE3]/80 dark:border-white/10"
122                onChange={(e) => {
123                  const text = e.target.value
124                  setItems((prevItems) =>
125                    prevItems.map((i) =>
126                      i.id === item.id ? { ...i, text } : i
127                    )
128                  )
129                }}
130              />
131            </motion.div>
132          </div>
133        ),
134      },
135      {
136        id: 1,
137        label: "Prompt",
138        content: (
139          <div className="flex flex-col  pr-2 ">
140            <motion.div
141              initial={{ opacity: 0, filter: "blur(4px)" }}
142              animate={{ opacity: 1, filter: "blur(0px)" }}
143              transition={{
144                type: "spring",
145                bounce: 0.2,
146                duration: 0.75,
147                delay: 0.15,
148              }}
149            >
150              <label className="text-xs text-neutral-400" htmlFor="prompt">
151                Prompt{" "}
152                <span className="lowercase">
153                  instructing your agent how to {item.text.slice(0, 20)}
154                </span>
155              </label>
156              <textarea
157                id="prompt"
158                className="h-[100px] w-full resize-none rounded-[6px]  bg-neutral-800 px-2 py-[2px] text-sm text-white placeholder:text-white/30 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#13EEE3]/80"
159                value={item.description}
160                placeholder="update agent prompt"
161                onChange={(e) => {
162                  const description = e.target.value
163                  setItems((prevItems) =>
164                    prevItems.map((i) =>
165                      i.id === item.id ? { ...i, description } : i
166                    )
167                  )
168                }}
169              />
170            </motion.div>
171          </div>
172        ),
173      },
174      {
175        id: 2,
176        label: "Settings",
177        content: (
178          <div className="flex flex-col py-2 px-1 ">
179            <motion.div
180              initial={{ opacity: 0, filter: "blur(4px)" }}
181              animate={{ opacity: 1, filter: "blur(0px)" }}
182              transition={{
183                type: "spring",
184                bounce: 0.2,
185                duration: 0.75,
186                delay: 0.15,
187              }}
188              className="space-y-3"
189            >
190              <p className="text-xs text-neutral-400">
191                AI settings for the{" "}
192                <span className="lowercase">
193                  {item.text.slice(0, 20)} stage
194                </span>
195              </p>
196              <div className="grid gap-4">
197                <div className="flex items-center justify-between">
198                  <label className="text-xs text-neutral-400" htmlFor="top-p">
199                    Top P
200                  </label>
201                  <div className="flex w-1/2 items-center gap-3">
202                    <span className="w-12 rounded-md  bg-black/20 px-2 py-0.5 text-right text-sm text-muted-foreground">
203                      {topP}
204                    </span>
205                    <Slider
206                      id="temperature"
207                      max={1}
208                      defaultValue={topP}
209                      step={0.1}
210                      onValueChange={setTopP}
211                      className="[&_[role=slider]]:h-8 [&_[role=slider]]:w-5 [&_[role=slider]]:rounded-md [&_[role=slider]]:border-neutral-100/10 [&_[role=slider]]:bg-neutral-900 [&_[role=slider]]:hover:border-[#13EEE3]/70 "
212                      aria-label="Top P"
213                    />
214                  </div>
215                </div>
216              </div>
217              <div className="grid gap-4">
218                <div className="flex items-center justify-between">
219                  <label className="text-xs text-neutral-400" htmlFor="top-p">
220                    Temperature
221                  </label>
222                  <div className="flex w-1/2 items-center gap-3">
223                    <span className="w-12 rounded-md  bg-black/20 px-2 py-0.5 text-right text-sm text-muted-foreground">
224                      {temp}
225                    </span>
226                    <Slider
227                      id="top-p"
228                      max={1}
229                      defaultValue={temp}
230                      step={0.1}
231                      onValueChange={setTemp}
232                      className="[&_[role=slider]]:h-8 [&_[role=slider]]:w-5 [&_[role=slider]]:rounded-md [&_[role=slider]]:border-neutral-100/10 [&_[role=slider]]:bg-neutral-900 [&_[role=slider]]:hover:border-[#13EEE3]/70"
233                      aria-label="Temperature"
234                    />
235                  </div>
236                </div>
237              </div>
238              <div className="grid gap-4">
239                <div className="flex items-center justify-between">
240                  <label className="text-xs text-neutral-400" htmlFor="top-p">
241                    Max Tokens
242                  </label>
243                  <div className="flex w-1/2 items-center gap-3">
244                    <span className="w-12 rounded-md  bg-black/20 px-2 py-0.5 text-right text-sm text-muted-foreground">
245                      {tokens}
246                    </span>
247                    <Slider
248                      id="max_tokens"
249                      max={1}
250                      defaultValue={tokens}
251                      step={0.1}
252                      onValueChange={setTokens}
253                      className="[&_[role=slider]]:h-8 [&_[role=slider]]:w-5 [&_[role=slider]]:rounded-md [&_[role=slider]]:border-neutral-100/10 [&_[role=slider]]:bg-neutral-900 [&_[role=slider]]:hover:border-[#13EEE3]/70"
254                      aria-label="Tokens"
255                    />
256                  </div>
257                </div>
258              </div>
259            </motion.div>
260          </div>
261        ),
262      },
263    ]
264 
265    return (
266      <SortableListItem
267        item={item}
268        order={order}
269        key={item.id}
270        isExpanded={isOpen}
271        onCompleteItem={onCompleteItem}
272        onRemoveItem={onRemoveItem}
273        handleDrag={handleCloseOnDrag}
274        className="my-2 "
275        renderExtra={(item) => (
276          <div
277            key={`${isOpen}`}
278            className={cn(
279              "flex h-full w-full flex-col items-center justify-center gap-2 ",
280              isOpen ? "py-1 px-1" : "py-3 "
281            )}
282          >
283            <motion.button
284              layout
285              onClick={() => setOpenItemId(!isOpen ? item.id : null)}
286              key="collapse"
287              className={cn(
288                isOpen
289                  ? "absolute right-3 top-3 z-10 "
290                  : "relative z-10 ml-auto mr-3 "
291              )}
292            >
293              {isOpen ? (
294                <motion.span
295                  initial={{ opacity: 0, filter: "blur(4px)" }}
296                  animate={{ opacity: 1, filter: "blur(0px)" }}
297                  exit={{ opacity: 1, filter: "blur(0px)" }}
298                  transition={{
299                    type: "spring",
300                    duration: 1.95,
301                  }}
302                >
303                  <XIcon className="h-5 w-5 text-neutral-500" />
304                </motion.span>
305              ) : (
306                <motion.span
307                  initial={{ opacity: 0, filter: "blur(4px)" }}
308                  animate={{ opacity: 1, filter: "blur(0px)" }}
309                  exit={{ opacity: 1, filter: "blur(0px)" }}
310                  transition={{
311                    type: "spring",
312                    duration: 0.95,
313                  }}
314                >
315                  <Settings2Icon className="stroke-1 h-5 w-5 text-white/80  hover:stroke-[#13EEE3]/70 " />
316                </motion.span>
317              )}
318            </motion.button>
319 
320            <LayoutGroup id={`${item.id}`}>
321              <AnimatePresence mode="popLayout">
322                {isOpen ? (
323                  <motion.div className="flex w-full flex-col ">
324                    <div className=" w-full  ">
325                      <motion.div
326                        initial={{
327                          y: 0,
328                          opacity: 0,
329                          filter: "blur(4px)",
330                        }}
331                        animate={{
332                          y: 0,
333                          opacity: 1,
334                          filter: "blur(0px)",
335                        }}
336                        transition={{
337                          type: "spring",
338                          duration: 0.15,
339                        }}
340                        layout
341                        className="  w-full"
342                      >
343                        <DirectionAwareTabs
344                          className="mr-auto bg-transparent pr-2"
345                          rounded="rounded "
346                          tabs={tabs}
347                          onChange={() =>
348                            setTabChangeRerender(tabChangeRerender + 1)
349                          }
350                        />
351                      </motion.div>
352                    </div>
353 
354                    <motion.div
355                      key={`re-render-${tabChangeRerender}`} //  re-animates the button section on tab change
356                      className="mb-2 flex w-full items-center justify-between pl-2"
357                      initial={{ opacity: 0, filter: "blur(4px)" }}
358                      animate={{ opacity: 1, filter: "blur(0px)" }}
359                      transition={{
360                        type: "spring",
361                        bounce: 0,
362                        duration: 0.55,
363                      }}
364                    >
365                      <motion.div className="flex items-center gap-2 pt-3">
366                        <div className="h-1.5 w-1.5 rounded-full bg-[#13EEE3]" />
367                        <span className="text-xs text-neutral-300/80">
368                          Changes
369                        </span>
370                      </motion.div>
371                      <motion.div layout className="ml-auto mr-1  pt-2">
372                        <Button
373                          size="sm"
374                          variant="ghost"
375                          onClick={() => {
376                            setOpenItemId(null)
377                            toast.info("Changes saved")
378                          }}
379                          className="h-7 rounded-lg bg-[#13EEE3]/80 hover:bg-[#13EEE3] hover:text-black text-black"
380                        >
381                          Apply Changes
382                        </Button>
383                      </motion.div>
384                    </motion.div>
385                  </motion.div>
386                ) : null}
387              </AnimatePresence>
388            </LayoutGroup>
389          </div>
390        )}
391      />
392    )
393  }
394 
395  return (
396    <div className="md:px-4 w-full max-w-xl ">
397      <div className="mb-9 rounded-2xl  p-2 shadow-sm md:p-6 dark:bg-[#151515]/50 bg-black">
398        <div className=" overflow-auto p-1  md:p-4">
399          <div className="flex flex-col space-y-2">
400            <div className="">
401              <svg
402                xmlns="http://www.w3.org/2000/svg"
403                width="256"
404                height="260"
405                preserveAspectRatio="xMidYMid"
406                viewBox="0 0 256 260"
407                className="h-6 w-6 fill-neutral-500 "
408              >
409                <path d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z" />
410              </svg>
411              <h3 className="text-neutral-200">Agent workflow</h3>
412              <a
413                className="text-xs text-white/80"
414                href="https://www.uilabs.dev/"
415                target="_blank"
416                rel="noopener noreferrer"
417              >
418                Inspired by <span className="text-[#13EEE3]"> @mrncst</span>
419              </a>
420            </div>
421            <div className="flex items-center justify-between gap-4 py-2">
422              <button disabled={items?.length > 5} onClick={handleAddItem}>
423                <Plus className="dark:text-netural-100 h-5 w-5 text-neutral-500/80 hover:text-white/80" />
424              </button>
425              <div data-tip="Reset task list">
426                <button onClick={handleResetItems}>
427                  <RepeatIcon className="dark:text-netural-100 h-4 w-4 text-neutral-500/80 hover:text-white/80" />
428                </button>
429              </div>
430            </div>
431            <SortableList
432              items={items}
433              setItems={setItems}
434              onCompleteItem={handleCompleteItem}
435              renderItem={renderListItem}
436            />
437          </div>
438        </div>
439      </div>
440    </div>
441  )
442}
443 
444export SortableListDemo