Loop Settings Form
Loop settings form card for audio or video tools: loop in and out times typed as m:ss, a large computed loop length, and fade in and out times in a collapsible section. Controlled or uncontrolled.
pnpm dlx shadcn add @01orbital/loop-settings-form"use client"
import { useId, useState, type ComponentProps, type ReactNode } from "react"
import { MinusIcon, PlusIcon } from "@phosphor-icons/react"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
const LABEL =
"font-mono text-xs font-normal tracking-widest text-muted-foreground uppercase"
// Times are m:ss strings, as typed.
type LoopSettings = {
loopIn: string
loopOut: string
fadeIn: string
fadeOut: string
}
// m:ss to seconds; anything else reads as not a time
function seconds(value: string) {
const match = /^(\d+):([0-5]\d)$/.exec(value.trim())
return match ? Number(match[1]) * 60 + Number(match[2]) : null
}
function clock(total: number) {
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`
}
function TimeField({
id,
label,
value,
onChange,
}: {
id: string
label: string
value: string
onChange: (value: string) => void
}) {
return (
<Field className="gap-2">
<FieldLabel htmlFor={id} className={LABEL}>
{label}
</FieldLabel>
<Input
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
className="border-input bg-transparent font-mono tabular-nums dark:bg-input/30"
/>
</Field>
)
}
type LoopSettingsFormProps = Omit<
ComponentProps<typeof Card>,
"title" | "children" | "defaultValue" | "onChange"
> & {
title: ReactNode
description?: ReactNode
// Controlled with value + onValueChange, or seeded with defaultValue.
value?: LoopSettings
defaultValue: LoopSettings
onValueChange?: (value: LoopSettings) => void
labels: {
length: string
loopIn: string
loopOut: string
fades: string
fadeIn: string
fadeOut: string
showFades: string
hideFades: string
}
defaultFadesOpen?: boolean
}
function LoopSettingsForm({
title,
description,
value,
defaultValue,
onValueChange,
labels,
defaultFadesOpen = false,
className,
...props
}: LoopSettingsFormProps) {
const id = useId()
const [isOpen, setIsOpen] = useState(defaultFadesOpen)
const [uncontrolled, setUncontrolled] = useState(defaultValue)
const times = value ?? uncontrolled
const set = (key: keyof LoopSettings) => (next: string) => {
const updated = { ...times, [key]: next }
if (value === undefined) setUncontrolled(updated)
onValueChange?.(updated)
}
const start = seconds(times.loopIn)
const end = seconds(times.loopOut)
const length =
start !== null && end !== null && end > start ? clock(end - start) : "–"
return (
<Card
data-slot="loop-settings-form"
className={cn("gap-8", className)}
{...props}
>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description != null && (
<CardDescription>{description}</CardDescription>
)}
</CardHeader>
<CardContent className="flex flex-col gap-8">
<span className="flex items-baseline gap-2">
<span className="font-display text-5xl tracking-tight tabular-nums">
{length}
</span>
<span className={LABEL}>{labels.length}</span>
</span>
<FieldGroup className="grid grid-cols-2 gap-4">
<TimeField
id={`loop-in-${id}`}
label={labels.loopIn}
value={times.loopIn}
onChange={set("loopIn")}
/>
<TimeField
id={`loop-out-${id}`}
label={labels.loopOut}
value={times.loopOut}
onChange={set("loopOut")}
/>
</FieldGroup>
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className="flex flex-col gap-3"
>
<Separator />
<p className="flex items-center justify-between">
<span className={LABEL}>{labels.fades}</span>
<CollapsibleTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={isOpen ? labels.hideFades : labels.showFades}
/>
}
>
{isOpen ? <MinusIcon /> : <PlusIcon />}
</CollapsibleTrigger>
</p>
<CollapsibleContent>
<FieldGroup className="grid grid-cols-2 gap-4 pt-1">
<TimeField
id={`fade-in-${id}`}
label={labels.fadeIn}
value={times.fadeIn}
onChange={set("fadeIn")}
/>
<TimeField
id={`fade-out-${id}`}
label={labels.fadeOut}
value={times.fadeOut}
onChange={set("fadeOut")}
/>
</FieldGroup>
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
)
}
export { LoopSettingsForm }
Usage
import { LoopSettingsForm } from "@/components/forms/loop-settings-form"Needs props to render
Pass
title, defaultValue, labels; see the API reference below.Examples
New
loop-settings-form-console
API reference
Prop
Type
It also accepts the HTML attributes of the element it renders.
Dependencies
npm: @phosphor-icons/react, cn. The CLI installs them for you.
Registry pieces it installs with it: