Skip to content
This repository was archived by the owner on Jul 31, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@
--border-neutral-hover: var(--color-neutral-500);
--border-neutral-default: var(--color-neutral-400);
--border-neutral-disabled: var(--color-neutral-400-40);
--border-neutral-softest: var(--color-neutral-300);
--border-neutral-softest: var(--color-neutral-200);
Comment thread
mcbiscuit5173 marked this conversation as resolved.
--border-neutral-inset: var(--color-base-white);
--border-neutral-alpha: hsla(
0,
Expand Down Expand Up @@ -698,7 +698,7 @@
--border-neutral-hover: var(--color-neutral-500);
--border-neutral-default: var(--color-neutral-600);
--border-neutral-disabled: var(--color-neutral-600-40);
--border-neutral-softest: var(--color-neutral-700);
--border-neutral-softest: var(--color-neutral-800-56);
--border-neutral-inset: var(--color-base-black);
--border-neutral-alpha: hsla(
255,
Expand Down
6 changes: 3 additions & 3 deletions src/components/Input/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function Input({
{...commonProps}
{...props}
className={cn(
'bg-surface-primary-default placeholder:text-placeholder text-default h-full w-full rounded-md text-sm shadow-none outline-none placeholder:transition-colors placeholder:duration-500 disabled:cursor-not-allowed disabled:opacity-50',
'bg-surface-primary-default placeholder:text-placeholder text-default h-full w-full text-sm shadow-none outline-none disabled:cursor-not-allowed disabled:opacity-50',
isFocused && 'placeholder:text-default'
)}
/>
Expand All @@ -72,7 +72,7 @@ export function Input({
cols={30}
rows={10}
className={cn(
'bg-surface-primary-default placeholder:text-placeholder text-default my-2 h-full max-h-60 min-h-16 w-full rounded-md px-3 py-3 text-sm shadow-none outline-none placeholder:transition-colors placeholder:duration-500 disabled:cursor-not-allowed disabled:opacity-50',
'bg-surface-primary-default placeholder:text-placeholder text-default my-2 h-full max-h-60 min-h-16 w-full px-3 py-3 text-sm shadow-none outline-none disabled:cursor-not-allowed disabled:opacity-50',
isFocused && 'placeholder:text-default'
)}
/>
Expand All @@ -82,7 +82,7 @@ export function Input({
return (
<div
className={cn(
'bg-surface-primary-default border-input text-muted-foreground flex items-center gap-3 rounded-md border px-4 py-3 transition-colors duration-300',
'bg-surface-primary-default border-input text-muted-foreground flex items-center gap-3 rounded-md border px-4 py-3',
icon && 'px-3',
isFocused && 'text-default border-focus',
error && 'border-destructive-default',
Expand Down
19 changes: 19 additions & 0 deletions src/components/SegmentedButton/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.active {
border-radius: 50px;
background: #f8f8f8;
box-shadow:
0 0 4px 0 rgba(0, 0, 0, 0.1),
0 2px 1px 0 #fff inset,
0 -2px 1px 0 rgba(0, 0, 0, 0.05) inset;
}

:global(.dark) {
.active {
background: #121212;
box-shadow:
0 0 6px 0 rgba(0, 0, 0, 0.35),
0 2px 1px 0 rgba(255, 255, 255, 0.08) inset,
0 -2px 1px 0 rgba(0, 0, 0, 0.45) inset;
}
}

104 changes: 104 additions & 0 deletions src/components/SegmentedButton/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Meta, StoryObj } from '@storybook/react'
import {
SegmentedButton,
SegmentedButtonItemProps,
SegmentedButtonProps,
} from '.'
import { useState } from 'react'
import React from 'react'

type Story = StoryObj<typeof SegmentedButton>

const meta: Meta<typeof SegmentedButton> = {
component: SegmentedButton,
tags: ['autodocs'],
}

export default meta

const StoryRenderer = (args: SegmentedButtonProps) => {
const getInitialActiveId = () => {
let foundActiveId: string | null = null
React.Children.forEach(args.children, (child) => {
if (
foundActiveId === null &&
React.isValidElement(child) &&
child.props.active &&
child.props.id
) {
foundActiveId = child.props.id as string
}
})

if (foundActiveId) return foundActiveId

let firstId: string | null = null
React.Children.forEach(args.children, (child) => {
if (firstId === null && React.isValidElement(child) && child.props.id) {
firstId = child.props.id as string
}
})

return firstId
}

const [activeId, setActiveId] = useState<string | null>(() =>
getInitialActiveId()
)
return (
<SegmentedButton {...args}>
{React.Children.map(args.children, (child) => {
if (!React.isValidElement(child)) {
return child
}

const props: SegmentedButtonItemProps = {
id: child.props.id ?? '',
active: child.props.id === activeId,
children: child.props.children,
onClick: () => setActiveId(child.props.id ?? ''),
}
return React.cloneElement(child, props)
})}
</SegmentedButton>
)
}

export const Default: Story = {
args: {
children: [
<SegmentedButton.Item id="chat">Chat</SegmentedButton.Item>,
<SegmentedButton.Item id="split" active>
Split
</SegmentedButton.Item>,
<SegmentedButton.Item id="code">Code</SegmentedButton.Item>,
],
},
render: (args) => <StoryRenderer {...args} />,
}

export const TwoItems: Story = {
args: {
children: [
<SegmentedButton.Item id="chat">Chat</SegmentedButton.Item>,
<SegmentedButton.Item id="split" active>
Split
</SegmentedButton.Item>,
],
},
render: (args) => <StoryRenderer {...args} />,
}

export const FourItems: Story = {
args: {
children: [
<SegmentedButton.Item id="chat">Chat</SegmentedButton.Item>,
<SegmentedButton.Item id="split" active>
Split
</SegmentedButton.Item>,
<SegmentedButton.Item id="code">Code</SegmentedButton.Item>,
<SegmentedButton.Item id="beast-mode">Beast Mode</SegmentedButton.Item>,
],
},
render: (args) => <StoryRenderer {...args} />,
}
108 changes: 108 additions & 0 deletions src/components/SegmentedButton/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { cn } from '@/lib/utils'
import { motion, MotionProps } from 'framer-motion'
import React from 'react'
import styles from './index.module.css'

export interface SegmentedButtonProps {
children: React.ReactNode
className?: string
}

const SegmentedButtonBase = ({ children, className }: SegmentedButtonProps) => {
const [hoveredId, setHoveredId] = React.useState<string | null>(null)

const enhancedChildren = React.Children.map(children, (child) => {
if (!React.isValidElement<SegmentedButtonItemProps>(child)) return child
const childId: string | undefined = child.props.id

const handleMouseEnter: React.MouseEventHandler<HTMLButtonElement> = (
e
) => {
setHoveredId(childId ?? null)
child.props.onMouseEnter?.(e)
}

const handleMouseLeave: React.MouseEventHandler<HTMLButtonElement> = (
e
) => {
setHoveredId((current) => (current === childId ? null : current))
child.props.onMouseLeave?.(e)
}

const highlighted = hoveredId ? childId === hoveredId : child.props.active

return React.cloneElement(
child as React.ReactElement<SegmentedButtonItemProps>,
{
highlighted,
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
}
)
})

return (
<div
className={cn(
'border-neutral-softest relative inline-flex flex-row items-center overflow-hidden rounded-full border shadow-xs backdrop-blur-md',
className
)}
onMouseLeave={() => setHoveredId(null)}
>
{enhancedChildren}
</div>
)
}

SegmentedButtonBase.displayName = 'SegmentedButton'

export interface SegmentedButtonItemProps extends MotionProps {
children: React.ReactNode
className?: string
active?: boolean
highlighted?: boolean
id: string
onClick?: () => void
onMouseEnter?: React.MouseEventHandler<HTMLButtonElement>
onMouseLeave?: React.MouseEventHandler<HTMLButtonElement>
}

const SegmentedButtonItem = ({
children,
className,
highlighted,
...props
}: SegmentedButtonItemProps) => {
return (
<motion.button
className="text-codeline-xs relative flex items-center rounded-full px-5 py-1 uppercase"
{...props}
>
{highlighted && (
<motion.span
layoutId="segmented-highlight"
className={cn(
'absolute inset-0 -z-10 rounded-full shadow-sm',
styles.active
)}
transition={{ type: 'spring', duration: 0.7, bounce: 0.3 }}
/>
)}
<span
className={cn(
'text-muted relative z-10',
highlighted && 'text-highlight',
className
)}
>
{children}
</span>
</motion.button>
)
}

SegmentedButtonItem.displayName = 'SegmentedButtonItem'

export const SegmentedButton = Object.assign(SegmentedButtonBase, {
Item: SegmentedButtonItem,
})
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export { Facepile, type FacepileProps } from '@/components/Facepile'
export { Link, type LinkProps } from '@/components/Link'
export { Dialog } from '@/components/Dialog'
export { Switch, type SwitchProps } from '@/components/Switch'

export { SegmentedButton } from '@/components/SegmentedButton'
// AppLayout
export {
AppLayout,
Expand Down